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:
| Layer | Role |
|---|---|
| nginx | Reverse proxy, SSL termination, static file caching |
| Strapi | Application server (Node.js) |
| PostgreSQL | Production database |
| S3 / Cloudinary | Media 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
.envto version control.
Strapi environment helpers
In configuration files, the env function is not just process.env - Strapi provides typed helpers:
| Helper | Returns | Example |
|---|---|---|
env('KEY') | string | env('HOST', '0.0.0.0') |
env.int() | number | env.int('PORT', 1337) |
env.bool() | boolean | env.bool('DATABASE_SSL', false) |
env.array() | string[] | env.array('APP_KEYS', []) |
env.json() | object | env.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
| Command | Description |
|---|---|
pm2 list | Show running processes |
pm2 logs strapi | View live logs |
pm2 restart strapi | Restart the app |
pm2 stop strapi | Stop the app |
pm2 delete strapi | Remove from PM2 |
pm2 monit | Real-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:
- Verifies you own the domain
- Obtains a certificate
- Modifies the nginx config to enable SSL
- 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::securitymiddleware already covers CSP and basic security headers. Only add helmet if you need additional headers beyond what Strapi provides. Disable helmet'scontentSecurityPolicyto 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:
| Check | Status |
|---|---|
| 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
| Flag | Use it for |
|---|---|
--file | Archive base name on export, archive path on import |
--no-encrypt | Plain archive for local-only backups |
--no-compress | Skip gzip compression when another tool handles it |
--key | Reuse a known encryption key for automated restores |
--only | Include only content, files, and/or config |
--exclude | Exclude content, files, and/or config |
--force | Skip 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.
| Flag | Use it for |
|---|---|
--from | Remote source URL when pulling into the local project |
--to | Remote destination URL when pushing local data |
--from-token | Transfer token for the remote source |
--to-token | Transfer token for the remote destination |
--only | Transfer only content, files, and/or config |
--exclude | Skip content, files, and/or config |
--force | Skip the destination-wipe confirmation |
--throttle | Delay 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 importandstrapi transferwipe destination data for the groups being restored or transferred. Groups left out with--onlyor--excludeare preserved. Back up the destination first, test the command on staging, and reserve--forcefor 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:
- Content Modeling Patterns - advanced schema design
- GraphQL Customization - if you need GraphQL
- Plugin Development - build your own plugins
- i18n and Multi-Locale - internationalization
- Performance and Caching - Redis, query optimization
- Admin Panel Customization - branding, custom fields
- Data Import, Export, and Migration - content migration strategies
- Scheduled Publishing - editorial workflows
- Testing Strapi Applications - unit and integration tests