Skip to content

Deploy

How to run a product in production: one VPS, Docker Compose, Caddy in front with automatic HTTPS. Each product deploys on its own from deploy/<product>/; Keelmint’s is deploy/keelmint/. Follow it top to bottom for a first deploy; the Updates and Backups sections are for afterwards.

:80 / :443
browser ──────────► web (Caddy) ──── /api/* ───► api (node main.cjs) ───► db (Postgres, optional)
│ automatic TLS │ storage volume
└─ the Angular build (/srv) └─ SMTP, S3, Google (outbound)
migrate (one-off: prisma migrate deploy) ──► db
FileWhat it is
apps/<product>/api/Dockerfilerunner stage: the API image (the default target). migrate stage: Prisma CLI, schema and migrations.
deploy/<product>/Dockerfile.webThe client’s production build, served by Caddy.
deploy/<product>/Caddyfile{$DOMAIN}: /api/* to api:3000, everything else the SPA (unknown paths fall back to index.html). Hashed bundles cached for a year, the rest revalidated.
deploy/<product>/docker-compose.ymlweb, api, migrate, db (profile db).
deploy/<product>/.env.exampleCompose variables, then the API’s production variables.
.github/workflows/release.ymlBuilds and pushes the three images of every product to GHCR.

pnpm nx g @core/plugin:product <name> creates all of these for a new product.

1. The images

From GitHub (recommended). Push a tag:

Terminal window
git tag v1.0.0 && git push origin v1.0.0

release.yml builds, for every directory with deploy/<product>/docker-compose.yml:

  • ghcr.io/<owner>/<product>-api
  • ghcr.io/<owner>/<product>-migrate
  • ghcr.io/<owner>/<product>-web

Each gets the tags 1.0.0, 1.0, latest and sha-<commit>. It can also be run by hand (Actions → Release images → Run workflow); a manual run gets only the sha- tag. New packages on GHCR are private: either make them public (package settings), or log the server in with a personal access token that has read:packages:

Terminal window
echo <token> | docker login ghcr.io -u <github user> --password-stdin

CI (ci.yml, job images) builds Keelmint’s three images on every push to main and every pull request. It also checks that the API image loads its runtime dependencies, so a broken image fails the build instead of the deploy.

On the server instead. Use a checkout of the repo and docker compose build (or up -d --build). Every service has a build: section next to its image:. Building the client needs about 2 GB of RAM.

2. The server

  • Docker Engine with the Compose plugin (v2.20 or newer, for depends_on.required).
  • A DNS A/AAAA record for your domain pointing at the server.
  • Ports 80 and 443 open, TCP, plus 443/UDP for HTTP/3. Caddy needs 80 to get the certificate.
  • An SMTP provider. Without one nobody can verify an email or reset a password.

Copy deploy/<product>/ to the server. A checkout of the repo is easiest; you need it anyway to build there.

3. The .env

Ask your AI assistant to “configure the production env for ”. The configure-env skill (.agents/skills/configure-env) asks one question at a time, generates the secrets, writes deploy/<product>/.env and checks it. Or copy .env.example and fill it in by hand. Either way, check it:

Terminal window
pnpm nx run core-api-env:check-env --file=deploy/<product>/.env

The check runs the API’s own schema and prints OK or one line per problem, never a value. It does not know variables a product adds through CoreModule.forRoot({ env }).

What production needs, beyond .env.example’s comments:

  • APP_URL=https://<DOMAIN>. CORS_ORIGIN stays unset: the client and the API share one origin, so the refresh cookie stays first-party.
  • TRUST_PROXY=1, since Caddy is the one proxy in front. Use 2 behind a CDN or load balancer as well, such as Cloudflare with proxying on. Never true.
  • JWT_SECRET, JOBS_ENCRYPTION_KEY and TWO_FACTOR_ENCRYPTION_KEY: three separate openssl rand -base64 32 values. Keep the two keys for the life of the deployment: changing them makes queued jobs and every enrolled 2FA secret unreadable.
  • Bundled database: COMPOSE_PROFILES=db, POSTGRES_PASSWORD from openssl rand -hex 24 (hex, so it is safe in a URL), and DATABASE_URL=postgres://<db>:<password>@db:5432/<db>. External database: leave COMPOSE_PROFILES empty and set DATABASE_URL; managed providers usually want ?sslmode=require.
  • STORAGE_DIR=/data/storage uses the storage volume for helpdesk uploads. For S3, R2 or MinIO, set STORAGE_DRIVER=s3 instead.
  • Google sign-in: register https://<DOMAIN>/api/auth/google/callback as the redirect URI.
  • Billing (when the product config has billing): STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICES, and a Stripe webhook endpoint https://<DOMAIN>/api/billing/webhook (Billing). The API refuses to boot without them.
  • NODE_ENV=production is in .env so that check-env applies the production rules; the compose file sets it (and PORT=3000) for the API container regardless.

.env and the skill’s .env.bak-* backups are ignored by git (the root .gitignore). Keep a copy in your password manager. Without the two keys, a restore is incomplete.

4. Start

Terminal window
cd deploy/<product>
docker compose pull # skip when building on the server
docker compose up -d # add --build to build on the server
docker compose ps

Expected: db healthy (bundled database only), then migrate exited (0), then api healthy, then web up. Caddy gets the certificate on the first request; docker compose logs web shows it. Open https://<DOMAIN>.

Order and failure:

  • migrate runs prisma migrate deploy and exits. The API starts only if it succeeded.
  • A failed migration leaves the old API running. With the first deploy there is no API yet, and docker compose logs migrate says why.
  • The API checks its environment at boot and exits with the list of problems. Read it with docker compose logs api.

5. The first platform admin

The base roles and, in single mode, the default organization come from the migrations. Seeds never run in production. The first platform admin is the one manual step:

  1. Register in the app with your address.
  2. Verify the email.
  3. Run, from deploy/<product>:
Terminal window
echo "UPDATE users SET \"isPlatformAdmin\" = true WHERE email = 'you@example.com';" \
| docker compose run --rm -T migrate prisma db execute --config libs/<product>/db/prisma.config.ts --stdin

This works with the bundled and with an external database, through the migrate image. Log out and in again. After that, platform admins promote each other from the Users page; keep at least two. Next, create the first organization and invite its owner (Organizations and units).

6. Updates

Terminal window
cd deploy/<product>
# IMAGE_TAG=1.1.0 in .env (or keep latest)
docker compose pull && docker compose up -d

migrate runs again before the new API starts. Already-applied migrations are skipped. Expect a few seconds of 502 while the API restarts: this setup has one API container, so it has no zero-downtime rollout.

Rollback: set IMAGE_TAG back and docker compose up -d. Migrations are not rolled back. Write them so that the previous release still works with the new schema (expand, then contract in a later release).

7. Backups

Nothing here backs anything up. What to keep:

  • The database. Bundled: docker compose exec -T db pg_dump -U <db> -Fc <db> > backup-$(date +%F).dump from a cron job, copied off the server. External: your provider’s backups.
  • The storage volume (helpdesk screenshots), unless you use S3: docker run --rm -v <project>_storage:/data -v "$PWD":/out alpine tar czf /out/storage.tgz -C /data .
  • .env.

caddy-data holds only the certificates, which Caddy can always get again. Keeping it avoids Let’s Encrypt’s rate limits on frequent redeploys.

8. Trying it locally

You can run the production stack next to the dev stack, as long as it uses its own project name and ports. The dev docker-compose.yml uses fixed container names and the ports 5433, 1025 and 8025. In deploy/keelmint/.env:

DOMAIN=http://localhost
APP_URL=http://localhost:8088
HTTP_PORT=8088
HTTPS_PORT=8443
IMAGE_PREFIX=local
IMAGE_TAG=trial
SMTP_HOST=mailpit
SMTP_PORT=1025
# ...the rest as for production, with generated secrets

Add Mailpit with an override file, for example trial.override.yml containing services: { mailpit: { image: axllent/mailpit, ports: ['8026:8025'] } }:

Terminal window
cd deploy/keelmint
docker compose -p keelmint-trial -f docker-compose.yml -f trial.override.yml up -d --build
# http://localhost:8088, mail at http://localhost:8026
docker compose -p keelmint-trial -f docker-compose.yml -f trial.override.yml down -v # remove it, volumes included

Deploying the docs site

These docs are a static site:

Terminal window
pnpm nx run site:build # writes apps/site/dist

Upload apps/site/dist to any static host (Cloudflare Pages, Netlify, an S3 bucket) or serve it from a Caddy file_server. Nothing in it talks to your API.

Not included

Several API containers, zero-downtime rollouts, Kubernetes, managed backups and monitoring are not included. A product with more than one client (@core/plugin:product-client) gets a web image for its main client only. For another client, copy Dockerfile.web and serve it from a second site block or path.