Deployment

This guide covers production deployment of FlameCMS using PM2, Docker, and Nginx. All three approaches are production-tested and actively maintained.

Before You Deploy

Build the production bundle:

npm run build

This compiles TypeScript, bundles the server, and outputs everything to ./dist. Run node dist/index.js to verify the build works locally before deploying.

Set NODE_ENV=production in your environment — this disables the SQL query logger, enables Pino JSON logging, and turns on response compression.

PM2 Deployment

PM2 is the recommended process manager for VPS and bare-metal deployments.

Install PM2

npm install -g pm2

ecosystem.config.js

Create ecosystem.config.js in your project root:

// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'flamecms',
      script: './dist/index.js',
      instances: 'max',      // one process per CPU core
      exec_mode: 'cluster',  // cluster mode for load balancing
      node_args: '--max-old-space-size=512',

      env_production: {
        NODE_ENV:     'production',
        PORT:         3001,
        HOST:         '127.0.0.1',   // only listen on localhost; Nginx proxies
      },

      // Log configuration
      out_file:   './logs/out.log',
      error_file: './logs/error.log',
      log_date_format: 'YYYY-MM-DD HH:mm:ss',
      merge_logs: true,

      // Auto-restart settings
      max_restarts:      10,
      min_uptime:        '5s',
      restart_delay:     4000,

      // Graceful shutdown
      kill_timeout:      5000,
      wait_ready:        true,
      listen_timeout:    10000,
    },
  ],
}

Start the process

# Load .env.production automatically
pm2 start ecosystem.config.js --env production

# Save the process list so it survives reboots
pm2 save

# Generate and enable systemd startup script
pm2 startup
# (follow the printed command)

Useful PM2 commands:

pm2 status           # view all processes
pm2 logs flamecms    # tail logs
pm2 reload flamecms  # zero-downtime reload
pm2 monit            # live CPU/memory dashboard

Docker Deployment

Dockerfile

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base
WORKDIR /app
ENV NODE_ENV=production

# ── Install dependencies ──────────────────────────────────────────────────────
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# ── Build ─────────────────────────────────────────────────────────────────────
FROM base AS builder
COPY package.json package-lock.json tsconfig.json flamecms.config.ts ./
COPY content-types ./content-types
COPY plugins ./plugins
RUN npm ci
RUN npm run build

# ── Production image ──────────────────────────────────────────────────────────
FROM base AS runner
RUN addgroup --system --gid 1001 flamecms \
 && adduser  --system --uid 1001 flamecms

# Copy production artifacts
COPY --from=deps    /app/node_modules ./node_modules
COPY --from=builder /app/dist         ./dist
COPY --from=builder /app/flamecms.config.ts ./flamecms.config.ts

RUN mkdir -p ./uploads ./logs \
 && chown -R flamecms:flamecms /app

USER flamecms
EXPOSE 3001

HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
  CMD wget -qO- http://localhost:3001/health || exit 1

CMD ["node", "dist/index.js"]

docker-compose.yml

# docker-compose.yml
version: '3.9'

services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER:     ${POSTGRES_USER:-flame}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB:       ${POSTGRES_DB:-flamecms}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    volumes:
      - redis_data:/data
    networks:
      - internal

  flamecms:
    build:
      context: .
      dockerfile: Dockerfile
      target: runner
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER:-flame}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-flamecms}
      REDIS_URL:    redis://redis:6379
      JWT_SECRET:   ${JWT_SECRET}
      SITE_URL:     ${SITE_URL}
      NODE_ENV:     production
      PORT:         3001
      HOST:         0.0.0.0
    volumes:
      - uploads:/app/uploads
      - logs:/app/logs
    ports:
      - "127.0.0.1:3001:3001"   # bind to localhost only; Nginx proxies
    networks:
      - internal
      - external

volumes:
  postgres_data:
  redis_data:
  uploads:
  logs:

networks:
  internal:
  external:

Start everything:

# Build and start
docker compose up -d --build

# Run database migrations inside the container
docker compose exec flamecms node dist/cli.js migrate

# View logs
docker compose logs -f flamecms

Nginx Reverse Proxy

nginx.conf

# /etc/nginx/sites-available/flamecms
upstream flamecms_backend {
    # Match the number of PM2/Docker instances
    server 127.0.0.1:3001;
    keepalive 32;
}

# Redirect HTTP → HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name cms.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name cms.example.com;

    # ── SSL certificates (managed by Certbot) ────────────────────────────────
    ssl_certificate     /etc/letsencrypt/live/cms.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cms.example.com/privkey.pem;
    include             /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;

    # ── Security headers ──────────────────────────────────────────────────────
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options           DENY                                  always;
    add_header X-Content-Type-Options    nosniff                               always;
    add_header Referrer-Policy           strict-origin-when-cross-origin       always;

    # ── Upload size ───────────────────────────────────────────────────────────
    client_max_body_size 50M;

    # ── Proxy to FlameCMS ─────────────────────────────────────────────────────
    location / {
        proxy_pass         http://flamecms_backend;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade           $http_upgrade;
        proxy_set_header   Connection        "upgrade";
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_read_timeout 120s;
        proxy_send_timeout 120s;
    }

    # ── Static uploads (served directly by Nginx) ────────────────────────────
    location /uploads/ {
        alias /srv/flamecms/uploads/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }
}

Enable the config:

ln -s /etc/nginx/sites-available/flamecms /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

SSL with Let's Encrypt

Install Certbot:

apt install -y certbot python3-certbot-nginx

Obtain a certificate:

certbot --nginx -d cms.example.com

Certbot will:

  1. Verify domain ownership via HTTP challenge
  2. Download the certificate to /etc/letsencrypt/live/cms.example.com/
  3. Automatically update your Nginx config
  4. Schedule auto-renewal via a systemd timer

Verify auto-renewal:

certbot renew --dry-run

Environment Variable Security

Never store secrets in your repository. Use one of these approaches:

.env file on the server (simplest)

# On the server
nano /srv/flamecms/.env.production
chmod 600 /srv/flamecms/.env.production

Load it in PM2:

// ecosystem.config.js
env_production: {
  // PM2 will read .env.production automatically
  // if dotenv is configured, or use:
  NODE_ENV: 'production',
}

Or export manually before start:

set -a && source .env.production && set +a
pm2 start ecosystem.config.js --env production
# docker-compose.yml
services:
  flamecms:
    secrets:
      - jwt_secret
      - db_password

secrets:
  jwt_secret:
    external: true   # created with: docker secret create jwt_secret -
  db_password:
    external: true

Generate safe secrets

# JWT_SECRET (64 hex characters = 256 bits)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# Or with openssl
openssl rand -hex 32

Health Checks and Monitoring

FlameCMS exposes a health endpoint:

curl https://cms.example.com/health
# → {"status":"ok","version":"1.0.0","db":"connected","uptime":3600}

Set up an uptime monitor (e.g., UptimeRobot, Better Uptime) pointing to /health.

For structured logs, pipe PM2 output to a log aggregator:

# Ship logs to Loki/Grafana with promtail
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7

Next