Skip to main content

Configuration & Deployment

Our blog CMS is feature-complete. Now let's deploy it to a production server. We will configure environment-based settings, switch to PostgreSQL, set up a process manager, put nginx in front, enable HTTPS, and harden security.

Deployment architecture​

The components:

LayerRole
nginxReverse proxy, SSL termination, static file caching
StrapiApplication server (Node.js)
PostgreSQLProduction database
S3 / CloudinaryMedia storage (chapter 10)
CDN (optional)Cache and deliver static assets globally

Environment-based configuration​

Strapi supports environment-specific configuration files. Instead of one config/database.ts, you can have:

config/
├── database.ts # Default (development)
└── env/
└── production/
└── database.ts # Production overrides

Strapi loads the default config first, then merges environment-specific overrides based on the NODE_ENV variable.

Environment variables​

All sensitive values go in .env (development) and environment variables on the server (production):

# .env (development - NEVER commit this file)
HOST=0.0.0.0
PORT=1337
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=your-api-token-salt
ADMIN_JWT_SECRET=your-admin-jwt-secret
TRANSFER_TOKEN_SALT=your-transfer-token-salt
JWT_SECRET=your-jwt-secret
ENCRYPTION_KEY=your-encryption-key
DATABASE_FILENAME=.tmp/data.db

Security: Generate all secrets with a cryptographic random generator. Never use default values in production. Never commit .env to version control.

Strapi environment helpers​

In configuration files, the env function is not just process.env - Strapi provides typed helpers:

HelperReturnsExample
env('KEY')stringenv('HOST', '0.0.0.0')
env.int()numberenv.int('PORT', 1337)
env.bool()booleanenv.bool('DATABASE_SSL', false)
env.array()string[]env.array('APP_KEYS', [])
env.json()objectenv.json('CUSTOM_CONFIG', {})

These are available in all config/ files and handle type conversion automatically. You will see them used throughout the configuration examples in this chapter.

Create the environment directory​

Strapi does not create the config/env/ directory for you. Create it before adding production-specific config:

mkdir -p config/env/production

You can create directories for any environment: development, staging, production, test, etc. The directory name must match the NODE_ENV value.

Production database - PostgreSQL​

SQLite is great for development but not suitable for production. Switch to PostgreSQL:

# Install the PostgreSQL client
npm install pg

Create the production database config:

// config/env/production/database.ts
export default ({ env }) => ({
connection: {
client: "postgres",
connection: {
host: env("DATABASE_HOST", "127.0.0.1"),
port: env.int("DATABASE_PORT", 5432),
database: env("DATABASE_NAME", "strapi"),
user: env("DATABASE_USERNAME", "strapi"),
password: env("DATABASE_PASSWORD", ""),
ssl: env.bool("DATABASE_SSL", false) && {
rejectUnauthorized: env.bool(
"DATABASE_SSL_REJECT_UNAUTHORIZED",
true
),
},
},
pool: {
min: 2,
max: 10,
},
},
});

Production server config​

// config/env/production/server.ts
export default ({ env }) => ({
host: env("HOST", "0.0.0.0"),
port: env.int("PORT", 1337),
url: env("PUBLIC_URL", "https://cms.yourdomain.com"),
proxy: {
koa: true,
maxIpsCount: env.int("PROXY_MAX_IPS_COUNT", 1),
},
app: {
keys: env.array("APP_KEYS"),
},
});

The url setting is critical - it tells Strapi its public URL, which is used for generating absolute URLs in API responses and the admin panel. The proxy block tells Strapi to trust nginx's forwarded headers; keep maxIpsCount equal to the number of trusted proxies in front of Strapi.

Production admin config​

// config/env/production/admin.ts
export default ({ env }) => ({
auth: {
secret: env("ADMIN_JWT_SECRET"),
},
apiToken: {
salt: env("API_TOKEN_SALT"),
},
transfer: {
token: {
salt: env("TRANSFER_TOKEN_SALT"),
},
},
secrets: {
encryptionKey: env("ENCRYPTION_KEY"),
},
});

Preparing the server​

We will use an Ubuntu VPS. If you followed the JavaScript or Java deployment chapters, you already have a server set up. The same server can host Strapi.

Install prerequisites​

# Update packages
sudo apt update && sudo apt upgrade -y

# Install Node.js 22 (LTS)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs

# Verify
node --version # v22.x.x
npm --version # 10.x.x

# Install PostgreSQL
sudo apt install -y postgresql postgresql-contrib

# Install nginx
sudo apt install -y nginx

# Install PM2 globally
sudo npm install -g pm2

Set up PostgreSQL​

# Switch to the postgres user
sudo -u postgres psql

# Create a database and user
CREATE USER strapi WITH PASSWORD 'your-strong-password-here';
CREATE DATABASE strapi_blog OWNER strapi;
GRANT ALL PRIVILEGES ON DATABASE strapi_blog TO strapi;
\q

Security: Use a strong, random password. The example above is a placeholder.

Create a system user​

Run Strapi as a dedicated non-root user:

sudo adduser --system --group --shell /bin/bash strapi
sudo mkdir -p /var/www/strapi
sudo chown strapi:strapi /var/www/strapi

Deploying the application​

Transfer files to the server​

From your local machine, use rsync to transfer the project:

rsync -avz --exclude node_modules --exclude .tmp --exclude .cache \
--exclude dist --exclude .env \
./ your-user@your-server:/var/www/strapi/

Install dependencies on the server​

# SSH into the server
ssh your-user@your-server

# Switch to the strapi directory
cd /var/www/strapi

# Install dependencies. Do not set NODE_ENV=production here:
# the admin build needs devDependencies.
npm ci

# Build the admin panel
NODE_ENV=production npm run build

# Optional: remove devDependencies after the build on space-constrained servers.
npm prune --omit=dev

Set up environment variables​

Create the production .env on the server:

sudo -u strapi nano /var/www/strapi/.env
# /var/www/strapi/.env
HOST=0.0.0.0
PORT=1337
NODE_ENV=production
PUBLIC_URL=https://cms.yourdomain.com

# Generate these with: openssl rand -base64 32
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=random-salt-here
ADMIN_JWT_SECRET=random-secret-here
TRANSFER_TOKEN_SALT=random-salt-here
JWT_SECRET=random-secret-here
ENCRYPTION_KEY=random-encryption-key-here

# PostgreSQL
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432
DATABASE_NAME=strapi_blog
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=your-strong-password-here
DATABASE_SSL=false

Generate random secrets:

openssl rand -base64 32 # Run this 6 times for each secret

Test the production build​

cd /var/www/strapi
NODE_ENV=production npm run start

Strapi should start on port 1337. Press Ctrl+C to stop it once you confirm it works.

Process management with PM2​

PM2 keeps Strapi running, restarts it on crash, and manages logs.

Create an ecosystem file​

// /var/www/strapi/ecosystem.config.js
module.exports = {
apps: [
{
name: "strapi",
cwd: "/var/www/strapi",
script: "npm",
args: "start",
env: {
NODE_ENV: "production",
},
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: "1G",
error_file: "/var/log/strapi/error.log",
out_file: "/var/log/strapi/out.log",
},
],
};

Create the log directory​

sudo mkdir -p /var/log/strapi
sudo chown strapi:strapi /var/log/strapi

Start with PM2​

cd /var/www/strapi
pm2 start ecosystem.config.js

# Save the process list so PM2 restarts on reboot
pm2 save

# Set PM2 to start on boot
pm2 startup systemd
# Follow the command it outputs (run as root)

PM2 commands​

CommandDescription
pm2 listShow running processes
pm2 logs strapiView live logs
pm2 restart strapiRestart the app
pm2 stop strapiStop the app
pm2 delete strapiRemove from PM2
pm2 monitReal-time monitoring dashboard

Alternative - systemd​

If you prefer systemd over PM2:

# /etc/systemd/system/strapi.service
[Unit]
Description=Strapi CMS
After=network.target postgresql.service

[Service]
Type=simple
User=strapi
Group=strapi
WorkingDirectory=/var/www/strapi
ExecStart=/usr/bin/npm run start
Restart=on-failure
RestartSec=10
Environment=NODE_ENV=production
EnvironmentFile=/var/www/strapi/.env
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable strapi
sudo systemctl start strapi

# Check status
sudo systemctl status strapi

# View logs
sudo journalctl -u strapi -f

nginx reverse proxy​

nginx sits in front of Strapi, handling SSL, compression, and caching.

Create the nginx configuration​

# /etc/nginx/sites-available/strapi
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

server {
listen 80;
server_name cms.yourdomain.com;

# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}

server {
listen 443 ssl http2;
server_name cms.yourdomain.com;

# SSL certificates (added by Certbot)
ssl_certificate /etc/letsencrypt/live/cms.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cms.yourdomain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 50m;

# Proxy to Strapi
location / {
proxy_pass http://127.0.0.1:1337;
proxy_http_version 1.1;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

# Increase timeouts for large uploads
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}

# Cache static assets
location /uploads/ {
proxy_pass http://127.0.0.1:1337;
proxy_cache_valid 200 1d;
add_header Cache-Control "public, max-age=86400";
}
}

Enable the site​

sudo ln -s /etc/nginx/sites-available/strapi /etc/nginx/sites-enabled/

# Test the configuration
sudo nginx -t

# Reload nginx
sudo systemctl reload nginx

HTTPS with Let's Encrypt​

Install Certbot​

sudo apt install -y certbot python3-certbot-nginx

Obtain a certificate​

First, create a basic nginx config without SSL (just the listen 80 block with server_name), then run Certbot:

sudo certbot --nginx -d cms.yourdomain.com

Certbot:

  1. Verifies you own the domain
  2. Obtains a certificate
  3. Modifies the nginx config to enable SSL
  4. Sets up automatic renewal

Verify auto-renewal​

sudo certbot renew --dry-run

Certbot sets up a systemd timer that renews certificates automatically before they expire.

Security hardening​

Firewall​

sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP (redirects to HTTPS)
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable

Do not open port 1337 - nginx proxies traffic to Strapi, so Strapi only needs to be accessible from localhost.

Strapi security configuration​

// config/env/production/middlewares.ts
export default [
"strapi::logger",
"strapi::errors",
{
name: "strapi::security",
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
"connect-src": ["'self'", "https:"],
"img-src": [
"'self'",
"data:",
"blob:",
"your-s3-bucket.s3.amazonaws.com", // Add your CDN/S3 domain
],
"media-src": [
"'self'",
"data:",
"blob:",
"your-s3-bucket.s3.amazonaws.com", // Add your CDN/S3 domain
],
upgradeInsecureRequests: null,
},
},
hsts: {
enabled: true,
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
},
},
{
name: "strapi::cors",
config: {
origin: [
"https://yourdomain.com",
"https://www.yourdomain.com",
],
methods: ["GET", "POST", "PUT", "DELETE"],
headers: ["Content-Type", "Authorization"],
credentials: true,
},
},
"strapi::poweredBy",
"strapi::query",
{
name: "strapi::body",
config: {
jsonLimit: "10mb", // Adjust based on your needs
textLimit: "10mb",
formLimit: "10mb",
formidable: {
maxFileSize: 200 * 1024 * 1024, // 200MB max file size
},
},
},
"strapi::session",
"strapi::favicon",
"strapi::public",
];

Note: Strapi does not ship with a built-in rate limiter. For production, use the custom rate limiting middleware from chapter 8, or install a third-party package like koa-ratelimit or koa2-ratelimit.

Additional security headers with Helmet​

For enhanced security, install and configure helmet:

npm install koa-helmet
// config/env/production/middlewares.ts
export default [
// ... other middleware
{
name: "global::helmet",
config: {},
},
// ... rest of middleware
];
// src/middlewares/helmet.ts
import helmet from "koa-helmet";

export default (config, { strapi }) => {
const helmetMiddleware = helmet({
crossOriginEmbedderPolicy: false,
contentSecurityPolicy: false,
crossOriginOpenerPolicy: { policy: "same-origin" },
crossOriginResourcePolicy: { policy: "cross-origin" },
dnsPrefetchControl: { allow: false },
frameguard: { action: "deny" },
hidePoweredBy: true,
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
ieNoOpen: true,
noSniff: true,
originAgentCluster: true,
permittedCrossDomainPolicies: false,
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
xssFilter: true,
});

return async (ctx, next) => {
await helmetMiddleware(ctx, next);
};
};

Note: Strapi's built-in strapi::security middleware already covers CSP and basic security headers. Only add helmet if you need additional headers beyond what Strapi provides. Disable helmet's contentSecurityPolicy to avoid conflicts with Strapi's own CSP configuration.

Restrict admin panel access​

In production, you may want to restrict admin panel access to specific IPs:

# In the nginx config
location /admin {
# Only allow specific IPs
allow 203.0.113.10; # Your office IP
deny all;

proxy_pass http://127.0.0.1:1337;
proxy_http_version 1.1;
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;
}

Disable unnecessary features​

In production, the Content-Type Builder is disabled by default (you cannot change schemas). Verify this is the case.

Also consider disabling GraphQL if you only use REST:

// config/env/production/plugins.ts
export default {
graphql: {
enabled: false,
},
};

SSH hardening​

# Disable password authentication (use SSH keys only)
sudo nano /etc/ssh/sshd_config
# Set: PasswordAuthentication no
# Set: PermitRootLogin no

sudo systemctl restart sshd

Fail2Ban​

sudo apt install -y fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

Automatic security updates​

sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Deployment checklist​

Before going live, verify:

CheckStatus
PostgreSQL configured and accessible
All environment variables set (secrets are unique, random)
.env is not in version control
Admin panel built (npm run build)
PM2 or systemd starts on boot
nginx reverse proxy configured
HTTPS enabled with valid certificate
Firewall allows only ports 22, 80, 443
CORS configured for your frontend domain(s)
Content Security Policy set
SSH hardened (key-only, no root login)
Fail2Ban installed
Automatic updates enabled
Backups configured (database + uploads)

Moving data between environments​

Once production is live, keep schema changes in Git and move blog content with Strapi's data-management CLI. For deeper restore and migration examples, see Data Import, Export, and Migration.

Typical blog workflows​

Back up production before a deploy or a risky import:

cd /var/www/strapi
npx strapi export --file backups/prod-$(date +%Y%m%d) --no-encrypt

Seed staging from a production archive:

# On production
npx strapi export --file prod-seed --key "$STRAPI_EXPORT_KEY"

# Copy prod-seed.tar.gz.enc to staging, then on staging:
npx strapi import --file prod-seed.tar.gz.enc --key "$STRAPI_EXPORT_KEY"

Push local editorial content to a remote staging instance:

npx strapi transfer --to https://staging-cms.example.com/admin \
--to-token "$STRAPI_STAGING_TRANSFER_TOKEN" \
--only content \
--force

Pull production content into local development:

npx strapi transfer --from https://cms.example.com/admin \
--from-token "$STRAPI_PROD_TRANSFER_TOKEN" \
--exclude files

Export/import flags​

FlagUse it for
--fileArchive base name on export, archive path on import
--no-encryptPlain archive for local-only backups
--no-compressSkip gzip compression when another tool handles it
--keyReuse a known encryption key for automated restores
--onlyInclude only content, files, and/or config
--excludeExclude content, files, and/or config
--forceSkip the destructive import confirmation

Example:

npx strapi export --file blog-content \
--no-compress \
--key "$STRAPI_EXPORT_KEY" \
--only content

Transfer flags and tokens​

strapi transfer connects two running Strapi instances directly. Create transfer tokens in the admin panel under Settings > Transfer Tokens. The token hash uses TRANSFER_TOKEN_SALT from config/admin.*, so keep that environment variable stable within each environment.

FlagUse it for
--fromRemote source URL when pulling into the local project
--toRemote destination URL when pushing local data
--from-tokenTransfer token for the remote source
--to-tokenTransfer token for the remote destination
--onlyTransfer only content, files, and/or config
--excludeSkip content, files, and/or config
--forceSkip the destination-wipe confirmation
--throttleDelay each transferred entity to reduce remote load

What moves and what does not​

Data export/import and transfer can move:

  • Content entries and relations (content)
  • Media Library files (files)
  • Strapi-managed configuration (config)
  • Schema metadata for strict matching

They do not move admin users, API tokens, transfer tokens, or plugin source code. Schema metadata is included so Strapi can verify compatibility, but you should still deploy the same code-level content-type schemas, plugins, and Strapi version before importing or transferring.

Media Library database records are part of content; files controls the binary assets. If you transfer content while excluding files, media relations can point to assets that were not copied.

Warning: strapi import and strapi transfer wipe destination data for the groups being restored or transferred. Groups left out with --only or --exclude are preserved. Back up the destination first, test the command on staging, and reserve --force for repeatable scripts.

Database backups​

Set up automated PostgreSQL backups:

# Create a backup script
sudo nano /opt/backup-strapi.sh
#!/bin/bash
BACKUP_DIR="/var/backups/strapi"
DATE=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=30

mkdir -p "$BACKUP_DIR"

# Dump the database
pg_dump -U strapi strapi_blog | gzip > "$BACKUP_DIR/db_$DATE.sql.gz"

# Remove old backups
find "$BACKUP_DIR" -name "db_*.sql.gz" -mtime +$RETENTION_DAYS -delete

echo "Backup completed: db_$DATE.sql.gz"
sudo chmod +x /opt/backup-strapi.sh

# Add to crontab (daily at 2 AM)
sudo crontab -e
# Add: 0 2 * * * /opt/backup-strapi.sh

Updating Strapi​

To deploy updates:

# On your local machine
rsync -avz --exclude node_modules --exclude .tmp --exclude .cache \
--exclude dist --exclude .env \
./ your-user@your-server:/var/www/strapi/

# On the server
cd /var/www/strapi
npm install
NODE_ENV=production npm run build
pm2 restart strapi

For zero-downtime deployments, consider using a blue-green deployment strategy or Docker containers.

For more advanced deployment patterns, see the Configuration and Deployment reference.

Summary​

You learned:

  • Environment-based configuration - separate settings for development and production
  • Switching to PostgreSQL for production
  • Deploying to a VPS - transferring files, installing dependencies, building
  • PM2 and systemd for process management
  • nginx as a reverse proxy with caching
  • HTTPS with Let's Encrypt and automatic renewal
  • Security hardening - firewall, CORS, CSP, SSH, Fail2Ban, automatic updates
  • Database backups and update procedures

Congratulations - your Strapi 5 blog CMS is now running in production! You have gone from a blank project to a fully configured, secured, and deployed content management system.

Where to go from here​

Now that you have completed the beginners guide, explore the reference docs for deeper topics: