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:
- Verify domain ownership via HTTP challenge
- Download the certificate to
/etc/letsencrypt/live/cms.example.com/ - Automatically update your Nginx config
- 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 secrets (recommended for Docker Compose)
# 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