Deployment

ForgeStack is deployable from day one. The whole stack is defined as Docker Compose, fronted by Caddy (automatic TLS), and shipped with scripts to provision a fresh server and deploy with one command.

The stack

A single Compose file brings up everything; in dev it uses official images with hot-reload bind mounts, in prod it uses pre-built, versioned images.

ServiceRole
public-pageNext.js frontend
service-1, service-2NestJS backend services
caddyreverse proxy + TLS termination
mongodbprimary datastore (mongo-express web viewer is dev-only)
rediscache / pub-sub
zookeeper + kafkaevent bus
prometheusmetrics
grafanadashboards
tempotraces
loki + promtaillogs
cadvisorcontainer metrics
portainercontainer management UI (in prod behind basic auth)

All services share one backend bridge network and address each other by name (kafka:9092, redis:6379, …).

Caddy & TLS

A single Caddyfile serves both dev and prod, switching TLS behaviour on an env var. Dev uses self-signed certs (tls internal); prod omits the block and Caddy provisions Let's Encrypt certificates automatically.

(tls-dev)  { tls internal }
(tls-prod) { }
 
{$PROTOCOL:http}://{$DOMAIN:localhost} {
  import tls-{$TLS_ENV:dev}
  import common
  handle_path /svc1/* { reverse_proxy service-1:3000 }
  handle_path /svc2/* { reverse_proxy service-2:3000 }
  handle { reverse_proxy public-page:3000 }
}
# (each site below also imports tls-{$TLS_ENV:dev})
{$PROTOCOL:http}://www.{$DOMAIN:localhost}       { redir {$PROTOCOL:http}://{$DOMAIN:localhost}{uri} permanent }
{$PROTOCOL:http}://grafana.{$DOMAIN:localhost}   { reverse_proxy grafana:3000 }
{$PROTOCOL:http}://portainer.{$DOMAIN:localhost} { import admin-auth-{$TLS_ENV:dev}; reverse_proxy portainer:9000 }
 
# Extra sites: in dev, infra/caddy/dev-only/ (e.g. mongo.<domain> for Mongo
# Express); in prod, <REMOTE_PATH>/compose/caddy-extra/ on the server.
# Both are mounted at /etc/caddy/extra; empty by default.
import /etc/caddy/extra/*.caddy

Everything the browser touches is served on one origin: the app at the root, and each service under a path prefix (/svc1, /svc2, ...) that Caddy strips before proxying, so each service still serves its own /api/v1. This keeps the auth cookies first-party. A service generated later gets its prefix by one rule: service-<N> is served at /svc<N>, any other name at /svc plus the name without dashes (see Adding a Service).

Operator surfaces keep their own subdomain: Grafana, and Portainer behind basic auth in production. Prometheus, Loki and Tempo are not routed at all; they are only reachable inside the Docker network, through Grafana. In dev, Swagger is served per service at /svc1/docs, /svc2/docs, …; it is off in production unless a service sets SWAGGER_ENABLED=true.

Serving other sites from the same server

The production Caddy can front other apps on the same box too. Drop one site block per file into <REMOTE_PATH>/compose/caddy-extra/ on the server (for example blog.caddy containing blog.example.com { reverse_proxy blog:3000 }), attach that app's container to the compose_backend network so Caddy reaches it by name, and run docker compose up -d caddy in <REMOTE_PATH>/compose. Deploys never write to that folder, so those sites survive every prod:update, and Caddy issues their certificates once their DNS points at the server.

The *.localhost dev domain

Browsers resolve any *.localhost name to 127.0.0.1 with no /etc/hosts edits, so localhost and grafana.localhost work out of the box in dev. Trust Caddy's local CA once and everything is HTTPS locally.

Local development

The dev stack runs the frontend and backend with hot reload (source is bind-mounted; next dev and the Nest watcher pick up changes). A start-dev script (npm run dev:start) ensures prerequisites, generates .env files from the examples, brings the stack up, and prints the local URLs:

WhatURLLogin
Apphttps://localhostthe seeded admin (DEFAULT_USER_EMAIL / DEFAULT_USER_PASSWORD in service-1's .env)
APIshttps://localhost/svc1/api/v1, https://localhost/svc2/api/v1the auth cookies
Swaggerhttps://localhost/svc1/docs, https://localhost/svc2/docsnone
Grafanahttps://grafana.localhostadmin / admin
Mongo Expresshttps://mongo.localhostadmin / admin
Portainerhttps://portainer.localhostcreate the admin on first visit

With SMTP_HOST empty (the dev default), service-1 prints the emails it would send (welcome and password-reset emails) to its logs instead.

env_file isn't hot-reloaded

Editing a service's .env requires recreating that container (up -d --force-recreate), not just a restart. Compose only reads env_file at container creation. Source/code changes hot-reload fine; env changes don't.

Production deploy

npm run prod:update runs infra/scripts/deploy.sh, which:

  1. Builds the images (frontend, backend, Caddy, and the monitoring images) in parallel.
  2. Pushes them to your container registry with versioned tags (<registry>/forgestack-<service>:vN).
  3. Bumps the version tags in docker-compose.prod.yml.
  4. Syncs the compose + config files to the server over rsync.
  5. Runs docker compose up -d on the server via SSH.

A separate setup-server.sh (run through npm run prod:setup) provisions a fresh Ubuntu host idempotently: installs Docker + the compose plugin, creates a non-root deploy user in the docker group with SSH-key auth and no sudo, hardens SSH (key-only logins), enables fail2ban and unattended security upgrades, opens the firewall for SSH/HTTP/HTTPS only, ensures swap, creates the deploy directory (/opt/forgestack) and installs a daily MongoDB backup cron.

The registry can be any registry (Docker Hub, GHCR, ECR, …): images are pushed as ${DOCKER_REGISTRY}/forgestack-<name>:vN, and the script logs in to the registry host taken from DOCKER_REGISTRY. Image tags are tracked in docker-compose.prod.yml, so commit it after each deploy.

First deploy checklist

Do these in order, from the repo root on your machine. You need a fresh Ubuntu server (as a rough guide, at least 4 GB of RAM; the setup adds 4 GB of swap, and 8 GB is comfortable), a domain, a registry account and an SSH key.

  1. Fill in the infra env file. Copy each infra/env/*.env.prod.example to *.env.prod, then fill in infra.env.prod: domain, registry, server IP, SERVER_USER (the deploy user that step 2 creates, e.g. deploy), and the datastore, Grafana and Portainer credentials. Use strong, generated secrets (openssl rand -base64 48).
  2. Provision the server with npm run prod:setup, then verify SSH as the deploy user (ssh deploy@<server-ip> 'docker ps').
  3. Fill in the service and frontend env files. JWT_SECRET and JWT_REFRESH_SECRET must be identical in every service, since service-2 verifies the tokens service-1 issues. Use one shared, real INTERNAL_API_KEY in every service's prod env file: deploy.sh checks this (and rejects the example values) and configures Prometheus with that key. The Mongo and Redis passwords must match infra.env.prod.
  4. Point DNS at the server. A records for your domain, www., grafana. and portainer.. Caddy needs them resolving to issue the TLS certificates.
  5. Deploy with npm run prod:update -- 5 (Update ALL), so the proxy and monitoring images are built and pushed too.
  6. Create the first admin. Either set DEFAULT_USER_EMAIL and DEFAULT_USER_PASSWORD in service-1.env.prod before the first deploy (service-1 seeds a verified admin only into an empty database, and only when both are set), or register on the site and set your user's role to admin in the auth_users collection with mongosh on the server, then sign in again.

This is the same order npm run prod:setup prints as its next steps. The step-by-step version, with commands, is in docs/deployment.md in the repository.

Environment configuration

Env templates live in infra/env/ (and per-service .env.examples). Keep the local, prod, and example files in sync in shape, even though values differ.

# infra.env.prod (excerpt)
DOMAIN=your-domain.com
PROTOCOL=https
TLS_ENV=prod
DOCKER_REGISTRY=docker.io/your-username
 
# service-1.env.prod (excerpt)
JWT_SECRET=...                 # required, 32+ chars, same in every service
JWT_REFRESH_SECRET=...         # required, 32+ chars, same in every service
INTERNAL_API_KEY=...           # same in every service; deploy.sh gives it to Prometheus
MONGODB_URI=mongodb://admin:your-mongo-password@mongodb:27017/app?replicaSet=rs0&authSource=admin
KAFKA_BROKERS=kafka:9092
OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4318

Secrets stay out of git

Real .env.prod files (JWT secrets, the internal API key, DB and registry credentials) are gitignored. Only the *.example templates are committed. Fill them in on your machine (the deploy script copies them to the server) and never commit the populated versions. In production a service refuses to boot with a JWT secret shorter than 32 characters or still set to an example value.

Making it yours

To turn ForgeStack into your product:

  1. Rename the service(s) and set your DOMAIN, registry and secrets in the env files (see Make It Yours).
  2. Build your bounded contexts following the Backend patterns. The lint rules will keep you on the rails.
  3. Add datastores or external adapters behind ports as needed.
  4. npm run prod:update to ship.

That's the full tour. Jump back to the Introduction any time, or explore the architecture diagram on the landing page.