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.
| Service | Role |
|---|---|
public-page | Next.js frontend |
service-1, service-2 | NestJS backend services |
caddy | reverse proxy + TLS termination |
mongodb | primary datastore (mongo-express web viewer is dev-only) |
redis | cache / pub-sub |
zookeeper + kafka | event bus |
prometheus | metrics |
grafana | dashboards |
tempo | traces |
loki + promtail | logs |
cadvisor | container metrics |
portainer | container 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/*.caddyEverything 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:
| What | URL | Login |
|---|---|---|
| App | https://localhost | the seeded admin (DEFAULT_USER_EMAIL / DEFAULT_USER_PASSWORD in service-1's .env) |
| APIs | https://localhost/svc1/api/v1, https://localhost/svc2/api/v1 | the auth cookies |
| Swagger | https://localhost/svc1/docs, https://localhost/svc2/docs | none |
| Grafana | https://grafana.localhost | admin / admin |
| Mongo Express | https://mongo.localhost | admin / admin |
| Portainer | https://portainer.localhost | create 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:
- Builds the images (frontend, backend, Caddy, and the monitoring images) in parallel.
- Pushes them to your container registry with versioned tags
(
<registry>/forgestack-<service>:vN). - Bumps the version tags in
docker-compose.prod.yml. - Syncs the compose + config files to the server over rsync.
- Runs
docker compose up -don 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.
- Fill in the infra env file. Copy each
infra/env/*.env.prod.exampleto*.env.prod, then fill ininfra.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). - Provision the server with
npm run prod:setup, then verify SSH as the deploy user (ssh deploy@<server-ip> 'docker ps'). - Fill in the service and frontend env files.
JWT_SECRETandJWT_REFRESH_SECRETmust be identical in every service, since service-2 verifies the tokens service-1 issues. Use one shared, realINTERNAL_API_KEYin every service's prod env file:deploy.shchecks this (and rejects the example values) and configures Prometheus with that key. The Mongo and Redis passwords must matchinfra.env.prod. - Point DNS at the server. A records for your domain,
www.,grafana.andportainer.. Caddy needs them resolving to issue the TLS certificates. - Deploy with
npm run prod:update -- 5(Update ALL), so the proxy and monitoring images are built and pushed too. - Create the first admin. Either set
DEFAULT_USER_EMAILandDEFAULT_USER_PASSWORDinservice-1.env.prodbefore 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'sroletoadminin theauth_userscollection withmongoshon 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:4318Secrets 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:
- Rename the service(s) and set your
DOMAIN, registry and secrets in the env files (see Make It Yours). - Build your bounded contexts following the Backend patterns. The lint rules will keep you on the rails.
- Add datastores or external adapters behind ports as needed.
npm run prod:updateto ship.
That's the full tour. Jump back to the Introduction any time, or explore the architecture diagram on the landing page.