Documentation/Learn/Docker Compose
In this section

Docker Compose

The fastest path from pnpm dev to "running on a server". Suitable for staging, single-host production, demo environments, and dev parity.

The minimal compose file

# docker-compose.yml
version: '3.9'

services:
  app:
    image: my-platform:latest
    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}
      HOME:         /var/lib/omnitron   # daemon state → $HOME/.omnitron
    volumes:
      - omnitron-data:/var/lib/omnitron
    ports:
      - '3001:3001'      # api HTTP (per-app Netron transport)
      - '9801:9801'      # daemon Netron HTTP RPC
    depends_on:
      postgres: { condition: service_healthy }
      redis:    { condition: service_healthy }
    command: ['omnitron', 'up', '--foreground']

  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
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    healthcheck:
      test:     ['CMD', 'redis-cli', 'ping']
      interval: 10s
      timeout:  5s
      retries:  5

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./infra/nginx/certs:/etc/nginx/certs:ro
    depends_on:
      - app

volumes:
  pg-data:
  redis-data:
  omnitron-data:

omnitron up --foreground keeps the daemon attached to the container's foreground so Docker's restart policy works.

The Console UI (port 9800) is served by a separate omnitron-nginx container that the daemon provisions over the Docker API, not by the daemon process itself. The daemon's own ports are: 9700 (TCP fleet RPC), 9801 (Netron HTTP RPC), 9802 (Netron WebSocket), 9803 (/metrics + /healthz). Nginx proxies 9800 → 9801/9802 via host.docker.internal. If you run the daemon inside this container and want it to manage sibling infra/console containers, mount the Docker socket (/var/run/docker.sock) and reach the daemon on the host. Many single-container deployments instead run the daemon with --no-infra and front it with their own nginx (the nginx service below).

The Dockerfile

# Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app

# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate

# Copy manifests for cache efficiency
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages packages
COPY apps     apps
COPY omnitron.config.ts ./

# Install + build
RUN pnpm install --frozen-lockfile
RUN pnpm build

# ---- Runtime stage ----
FROM node:22-alpine
WORKDIR /app

RUN apk add --no-cache tini && corepack enable && corepack prepare pnpm@latest --activate

# Copy built artefacts only
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages      ./packages
COPY --from=builder /app/apps          ./apps
COPY --from=builder /app/omnitron.config.ts ./

# Non-root user
RUN addgroup -S omni && adduser -S omni -G omni && \
    mkdir -p /var/lib/omnitron && chown -R omni:omni /var/lib/omnitron
USER omni

# Daemon state lives under $HOME/.omnitron — point HOME at the volume.
ENV NODE_ENV=production HOME=/var/lib/omnitron
EXPOSE 3001 9801 9802 9803

ENTRYPOINT ['/sbin/tini', '--']
CMD ['pnpm', 'omnitron', 'up', '--foreground']

Key choices:

  • Multi-stage: build artefacts left behind in the builder stage; runtime image stays small (~150 MB).

  • tini PID 1: reaps zombie processes from forked workers; forwards signals correctly to the daemon.

  • Non-root user: standard hardening.

  • No npm install at runtime: predictable image.

.env file

# .env (gitignored)
DB_PASS=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 48)

docker compose auto-loads .env from the working directory. For real deploys, source from a secret manager and inject as env at container start.

Volumes

Three persistent volumes:

VolumeMountPurpose
pg-data/var/lib/postgresql/dataPostgres data
redis-data/dataRedis snapshots
omnitron-data/var/lib/omnitronDaemon state ($HOME/.omnitron): SQLite state at data/daemon-state.db, secrets, logs

Back up pg-data regularly — that's the only volume whose loss is catastrophic. The rest can be reconstructed.

# Backup
docker compose exec postgres pg_dumpall -U platform > backup.sql

# Or use the built-in:
docker compose exec app omnitron backup create

Build + ship

# Build local:
docker compose build

# Tag + push to a registry:
docker build -t registry.example.com/platform:$(git rev-parse --short HEAD) .
docker push registry.example.com/platform:$(git rev-parse --short HEAD)

# Deploy on the target host:
ssh server "cd /srv/platform && docker compose pull && docker compose up -d"

Health checks

The compose healthcheck for the app container:

services:
  app:
    healthcheck:
      test:         ['CMD', 'pnpm', 'omnitron', 'ping']
      interval:     30s
      timeout:      10s
      retries:      3
      start_period: 30s     # grace for cold start

omnitron ping returns 0 when the daemon is reachable.

Logging

App logs go to stdout (pino JSON); compose forwards to its logging driver:

services:
  app:
    logging:
      driver: 'json-file'
      options:
        max-size: '50m'
        max-file: '10'

For production, ship to Loki / Datadog / Cloudwatch via the docker logging driver:

services:
  app:
    logging:
      driver: 'loki'
      options:
        loki-url: 'https://loki.example.com/loki/api/v1/push'

Resource limits

services:
  app:
    deploy:
      resources:
        limits:
          cpus:   '2.0'
          memory: 2G
        reservations:
          cpus:   '0.5'
          memory: 512M

docker compose up honours these as soft limits; docker swarm deploy enforces them.

Updating the stack

Zero-downtime rolling update on a single host:

# Pull the new image:
docker compose pull app

# Recreate just the app container (DB + Redis stay up):
docker compose up -d --no-deps app

For true zero-downtime, run the daemon in the container with omnitron reload instead of restart:

docker compose exec app omnitron reload

This works because reload cycles workers within the running daemon — no container restart.

Compose for dev

A separate docker-compose.dev.yml:

version: '3.9'

services:
  postgres:
    extends:
      file: docker-compose.yml
      service: postgres
    ports:
      - '5432:5432'      # expose to host for psql

  redis:
    extends:
      file: docker-compose.yml
      service: redis
    ports:
      - '6379:6379'

# No 'app' service — run the app locally via `pnpm dev`

Boot:

docker compose -f docker-compose.dev.yml up -d
pnpm dev                                # app runs on host

What this doesn't give you

  • Multi-host — for that, docker swarm deploy or move to Kubernetes.

  • Per-app autoscaling based on CPU/memory metrics — Omnitron's autoscaler works inside the daemon, not at the container level.

  • Managed TLS / DNS — bring nginx-proxy / Caddy / Traefik in front, or a managed LB.

  • Multi-region — deploy per region; coordinate via DNS or Omnitron cluster mode.

For those, see Kubernetes or Cluster + Fleet.

See also