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
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: parseInt(env("DATABASE_PORT", "5432"), 10),
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: parseInt(env("PORT", "1337"), 10),
url: env("PUBLIC_URL", "https://cms.yourdomain.com"),
app: {
keys: env("APP_KEYS", "").split(","),
},
});
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.
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"),
},
},
});
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 20 (LTS)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# Verify
node --version # v20.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 production dependencies
NODE_ENV=production npm install
# Build the admin panel
NODE_ENV=production npm run build
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
# 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 5 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: "run 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/node node_modules/.bin/strapi 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
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;
# 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 "upgrade";
# Increase timeouts for large uploads
proxy_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 50m;
}
# 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";
}
# Cache admin panel assets
location /admin/ {
proxy_pass http://127.0.0.1:1337;
proxy_cache_valid 200 7d;
add_header Cache-Control "public, max-age=604800";
}
}
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) |
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