Documentation/Learn/6. Deploy
In this section

Step 6 — Deploy

By the end: a docker-compose.yml running the api + webapp + Postgres + Redis on a single server, with TLS via nginx.

Build the api

apps/api/Dockerfile:

FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps     apps
COPY packages packages
RUN pnpm install --frozen-lockfile
RUN pnpm -F api build

FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache tini && corepack enable && corepack prepare pnpm@latest --activate
COPY --from=build /app/node_modules         ./node_modules
COPY --from=build /app/apps/api/dist        ./apps/api/dist
COPY --from=build /app/apps/api/package.json ./apps/api/
COPY --from=build /app/packages              ./packages
COPY --from=build /app/apps/api/migrations  ./apps/api/migrations

RUN addgroup -S app && adduser -S app -G app
USER app

ENV NODE_ENV=production
EXPOSE 3001
ENTRYPOINT ['/sbin/tini', '--']
CMD ['node', '/app/apps/api/dist/main.js']

Build the webapp

apps/web/Dockerfile:

FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps     apps
COPY packages packages
RUN pnpm install --frozen-lockfile

ARG VITE_API_URL=https://api.example.com
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm -F web build

FROM nginx:alpine
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

apps/web/nginx.conf:

server {
  listen 80 default_server;
  root /usr/share/nginx/html;
  index index.html;

  # SPA fallback
  location / {
    try_files $uri $uri/ /index.html;
  }

  # Static-asset caching
  location ~* \.(?:js|css|woff2?|svg|png|jpg|jpeg|webp)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
}

docker-compose.yml

version: '3.9'

services:
  api:
    build:
      context:    .
      dockerfile: apps/api/Dockerfile
    restart: unless-stopped
    environment:
      NODE_ENV:     production
      DATABASE_URL: postgres://platform:${DB_PASS}@postgres__LUMEN_COLON__5432/platform
      REDIS_URL:    redis://redis:6379
      JWT_SECRET:   ${JWT_SECRET}
      API_LOG_LEVEL: info
    depends_on:
      postgres: { condition: service_healthy }
      redis:    { condition: service_healthy }
      migrate:  { condition: service_completed_successfully }
    healthcheck:
      test:     ['CMD', 'wget', '-qO-', 'http://localhost:3001/healthz']
      interval: 30s
      timeout:  10s
      retries:  3

  migrate:
    build:
      context:    .
      dockerfile: apps/api/Dockerfile
    command: ['sh', '-c', 'cat /app/apps/api/migrations/*.sql | psql ${DATABASE_URL}']
    environment:
      DATABASE_URL: postgres://platform:${DB_PASS}@postgres__LUMEN_COLON__5432/platform
    depends_on:
      postgres: { condition: service_healthy }
    restart: 'no'

  web:
    build:
      context:    .
      dockerfile: apps/web/Dockerfile
      args:
        VITE_API_URL: https://api.example.com
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER:     platform
      POSTGRES_PASSWORD: ${DB_PASS}
      POSTGRES_DB:       platform
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test:     ['CMD', 'pg_isready', '-U', 'platform']
      interval: 10s
      timeout:  5s
      retries:  5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    healthcheck:
      test:     ['CMD', 'redis-cli', 'ping']
      interval: 10s
      timeout:  5s
      retries:  5

  caddy:
    image: caddy:alpine
    restart: unless-stopped
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - api
      - web

volumes:
  pg-data:
  caddy-data:
  caddy-config:

Caddyfile (TLS auto-provisioned)

api.example.com {
  reverse_proxy api:3001
  encode gzip
}

app.example.com {
  reverse_proxy web:80
  encode gzip
}

Caddy auto-provisions Let's Encrypt certs on first start — no certbot setup needed.

.env (gitignored)

DB_PASS=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 48)

Deploy script

#!/bin/bash
# deploy.sh
set -euo pipefail

SERVER="${1:?usage: deploy.sh user@host}"

echo "→ Building images locally..."
docker compose build

echo "→ Saving images..."
docker save \
  $(docker compose config --images) \
  | gzip > /tmp/images.tar.gz

echo "→ Shipping to $SERVER..."
ssh "$SERVER" 'mkdir -p /srv/platform'
scp /tmp/images.tar.gz docker-compose.yml Caddyfile "$SERVER:/srv/platform/"
scp .env "$SERVER:/srv/platform/.env"

echo "→ Loading + deploying..."
ssh "$SERVER" '
  cd /srv/platform
  docker load < images.tar.gz
  docker compose up -d --remove-orphans
'

echo "✓ Deployed."

Use:

chmod +x deploy.sh
./deploy.sh admin@your-server.example.com

DNS

Point api.example.com and app.example.com to your server's IP. Caddy will auto-provision Let's Encrypt certificates on first request.

Verify

After ~1 minute (TLS cert acquisition):

curl https://api.example.com/healthz
# {"status":"healthy"}

open https://app.example.com
# Webapp loads; sign in works.

Updates

./deploy.sh admin@your-server.example.com

Re-runs the deploy. Docker Compose detects new images and recreates the api/web containers; Postgres + Redis stay up.

For zero-downtime API updates use:

ssh admin@your-server.example.com 'cd /srv/platform && docker compose exec api kill -USR2 1'

If your api had multiple workers (via instances > 1), they'd cycle one-by-one without dropping traffic.

Backups

Daily Postgres dump:

# /etc/cron.d/platform-backup
30 2 * * *  admin  docker exec platform-postgres-1 pg_dump -U platform platform | gzip > /backups/platform-$(date +\%Y\%m\%d).sql.gz

For object storage of backups, push to S3 (or any S3-compatible) in a follow-up step.

Monitoring

  • Logs — docker compose logs -f --tail 100. For real aggregation, ship to Loki via the docker logging driver.

  • Metrics — scrape api:3001/metrics from a Prometheus container.

  • Uptime — external ping of /healthz.

What you've shipped

LayerImplementation
BackendTitan app with auth + Postgres + Redis
FrontendReact + Prism + netron-react
WireNetron HTTP with MessagePack + JWT
PersistencePostgres + Redis
Reverse proxyCaddy with auto-TLS
Container orchestrationDocker Compose
TestsUnit + integration + E2E with Vitest + Playwright
DeployOne-command shell script

Where to go from here

WantWhere
Multi-app supervisionAdd @omnitron-dev/omnitron daemon — Omnitron overview
Web console UIConsole
Multi-node fleetCluster + Fleet
K8s deploymentKubernetes guide
More modulesModule catalogue
Production patternsBest practices + Recipes

Commit

git add .
git commit -m "step 6: docker deploy + caddy TLS"
git tag v0.1.0

Congratulations

Six steps, ~2.5 hours, full-stack production-shaped TypeScript app with zero codegen. Now go build something real with it.

→ Reference docs