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| File | What it is |
|---|---|
apps/<product>/api/Dockerfile | runner stage: the API image (the default target). migrate stage: Prisma CLI, schema and migrations. |
deploy/<product>/Dockerfile.web | The 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.yml | web, api, migrate, db (profile db). |
deploy/<product>/.env.example | Compose variables, then the API’s production variables. |
.github/workflows/release.yml | Builds 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:
git tag v1.0.0 && git push origin v1.0.0release.yml builds, for every directory with deploy/<product>/docker-compose.yml:
ghcr.io/<owner>/<product>-apighcr.io/<owner>/<product>-migrateghcr.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:
echo <token> | docker login ghcr.io -u <github user> --password-stdinCI (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 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:
pnpm nx run core-api-env:check-env --file=deploy/<product>/.envThe 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_ORIGINstays 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. Use2behind a CDN or load balancer as well, such as Cloudflare with proxying on. Nevertrue.JWT_SECRET,JOBS_ENCRYPTION_KEYandTWO_FACTOR_ENCRYPTION_KEY: three separateopenssl rand -base64 32values. 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_PASSWORDfromopenssl rand -hex 24(hex, so it is safe in a URL), andDATABASE_URL=postgres://<db>:<password>@db:5432/<db>. External database: leaveCOMPOSE_PROFILESempty and setDATABASE_URL; managed providers usually want?sslmode=require. STORAGE_DIR=/data/storageuses thestoragevolume for helpdesk uploads. For S3, R2 or MinIO, setSTORAGE_DRIVER=s3instead.- Google sign-in: register
https://<DOMAIN>/api/auth/google/callbackas the redirect URI. - Billing (when the product config has
billing):STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRICES, and a Stripe webhook endpointhttps://<DOMAIN>/api/billing/webhook(Billing). The API refuses to boot without them. NODE_ENV=productionis in.envso thatcheck-envapplies the production rules; the compose file sets it (andPORT=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
cd deploy/<product>docker compose pull # skip when building on the serverdocker compose up -d # add --build to build on the serverdocker compose psExpected: 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:
migraterunsprisma migrate deployand 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 migratesays 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:
- Register in the app with your address.
- Verify the email.
- Run, from
deploy/<product>:
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 --stdinThis 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
cd deploy/<product># IMAGE_TAG=1.1.0 in .env (or keep latest)docker compose pull && docker compose up -dmigrate 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).dumpfrom a cron job, copied off the server. External: your provider’s backups. - The
storagevolume (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://localhostAPP_URL=http://localhost:8088HTTP_PORT=8088HTTPS_PORT=8443IMAGE_PREFIX=localIMAGE_TAG=trialSMTP_HOST=mailpitSMTP_PORT=1025# ...the rest as for production, with generated secretsAdd Mailpit with an override file, for example trial.override.yml containing services: { mailpit: { image: axllent/mailpit, ports: ['8026:8025'] } }:
cd deploy/keelmintdocker compose -p keelmint-trial -f docker-compose.yml -f trial.override.yml up -d --build# http://localhost:8088, mail at http://localhost:8026docker compose -p keelmint-trial -f docker-compose.yml -f trial.override.yml down -v # remove it, volumes includedDeploying the docs site
These docs are a static site:
pnpm nx run site:build # writes apps/site/distUpload 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.