Deploying Strapi Headless CMS on Your Own Server: Complete Self-Hosted Guide
Step-by-step guide to self-hosting Strapi v5 headless CMS with PostgreSQL, Nginx reverse proxy, and automated backups. Save thousands compared to Strapi Cloud.
Why Self-Host Strapi?
Strapi is the leading open-source headless CMS with over 60k GitHub stars. While Strapi Cloud offers a managed experience, self-hosting gives you full control over your data, unlimited API calls, and zero per-seat pricing.
<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="200" rx="12" fill="#1a1a2e"/><rect x="60" y="30" width="140" height="140" rx="6" fill="none" stroke="#e2e8f0" stroke-width="1.5"/><text x="130" y="24" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Production</text><rect x="70" y="40" width="120" height="22" rx="3" fill="#6366f1" opacity="0.8"/><circle cx="82" cy="51" r="3" fill="#2dd4bf"/><text x="130" y="55" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Web Server</text><rect x="70" y="68" width="120" height="22" rx="3" fill="#6366f1" opacity="0.8"/><circle cx="82" cy="79" r="3" fill="#2dd4bf"/><text x="130" y="83" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">App Server</text><rect x="70" y="96" width="120" height="22" rx="3" fill="#a855f7" opacity="0.8"/><circle cx="82" cy="107" r="3" fill="#2dd4bf"/><text x="130" y="111" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Database</text><rect x="70" y="124" width="120" height="22" rx="3" fill="#f59e0b" opacity="0.6"/><circle cx="82" cy="135" r="3" fill="#2dd4bf"/><text x="130" y="139" text-anchor="middle" fill="#1a1a2e" font-size="9" font-family="system-ui">Monitoring</text><rect x="290" y="30" width="140" height="140" rx="6" fill="none" stroke="#e2e8f0" stroke-width="1.5"/><text x="360" y="24" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Staging</text><rect x="300" y="40" width="120" height="22" rx="3" fill="#3b82f6" opacity="0.6"/><circle cx="312" cy="51" r="3" fill="#2dd4bf"/><text x="360" y="55" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Web Server</text><rect x="300" y="68" width="120" height="22" rx="3" fill="#3b82f6" opacity="0.6"/><circle cx="312" cy="79" r="3" fill="#2dd4bf"/><text x="360" y="83" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">App Server</text><rect x="300" y="96" width="120" height="22" rx="3" fill="#a855f7" opacity="0.5"/><circle cx="312" cy="107" r="3" fill="#f59e0b"/><text x="360" y="111" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Database</text><line x1="200" y1="100" x2="290" y2="100" stroke="#2dd4bf" stroke-width="1.5" stroke-dasharray="5,3"/><text x="245" y="95" text-anchor="middle" fill="#2dd4bf" font-size="8" font-family="system-ui">VLAN</text><rect x="480" y="60" width="90" height="70" rx="6" fill="none" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,3"/><text x="525" y="85" text-anchor="middle" fill="#f59e0b" font-size="9" font-family="system-ui">Backup</text><text x="525" y="100" text-anchor="middle" fill="#f59e0b" font-size="9" font-family="system-ui">Storage</text><text x="525" y="115" text-anchor="middle" fill="#94a3b8" font-size="8" font-family="system-ui">3-2-1 Rule</text><line x1="430" y1="100" x2="478" y2="95" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,3"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Server infrastructure: production and staging environments connected via VLAN with offsite backups.</p></div>
At TechSaaS, we deploy Strapi for clients who need a flexible content API without the recurring SaaS fees. A typical Strapi Cloud plan costs \$99-499/month. Self-hosted? About \$5/month in resources on existing infrastructure.
Prerequisites
Docker Compose Setup
Create a docker-compose.yml for your Strapi deployment:
version: "3.8"
services:
strapi:
image: node:20-alpine
working_dir: /app
command: sh -c "npm install && npm run build && npm run start"
volumes:
- ./strapi-app:/app
- strapi_node_modules:/app/node_modules
environment:
DATABASE_CLIENT: postgres
DATABASE_HOST: strapi-db
DATABASE_PORT: 5432
DATABASE_NAME: strapi
DATABASE_USERNAME: strapi
DATABASE_PASSWORD: your-secure-password-here
DATABASE_SSL: "false"
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
NODE_ENV: production
ports:
- "1337:1337"
depends_on:
strapi-db:
condition: service_healthy
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.strapi.rule=Host(`cms.example.com`)"
- "traefik.http.services.strapi.loadbalancer.server.port=1337"
strapi-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: strapi
POSTGRES_USER: strapi
POSTGRES_PASSWORD: your-secure-password-here
volumes:
- strapi_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U strapi"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
strapi_data:
strapi_node_modules:Creating a New Strapi Project
If starting from scratch, create the Strapi app locally first:
npx create-strapi@latest strapi-app \
--dbclient postgres \
--dbhost localhost \
--dbport 5432 \
--dbname strapi \
--dbusername strapi \
--dbpassword your-secure-password-here \
--no-runThen copy the project directory to your server.
Production Configuration
Database Configuration
Edit config/database.ts for production-ready settings:
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),
},
pool: {
min: 2,
max: 10,
acquireTimeoutMillis: 300000,
createTimeoutMillis: 300000,
destroyTimeoutMillis: 5000,
idleTimeoutMillis: 30000,
reapIntervalMillis: 1000,
},
},
});Server Configuration
Configure the server for production in config/server.ts:
export default ({ env }) => ({
host: env("HOST", "0.0.0.0"),
port: env.int("PORT", 1337),
url: env("PUBLIC_URL", "https://cms.example.com"),
app: {
keys: env.array("APP_KEYS"),
},
webhooks: {
populateRelations: false,
},
});<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 170" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="170" rx="12" fill="#1a1a2e"/><path d="M80,90 Q80,50 120,50 Q130,30 160,35 Q190,25 200,50 Q230,45 230,70 Q240,90 210,95 L100,95 Q70,95 80,90 Z" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="155" y="75" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">Cloud</text><text x="155" y="120" text-anchor="middle" fill="#94a3b8" font-size="9" font-family="system-ui">$5,000/mo</text><defs><marker id="arrow9" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><path d="M0,0 L10,3.5 L0,7" fill="#2dd4bf"/></marker></defs><line x1="245" y1="70" x2="340" y2="70" stroke="#2dd4bf" stroke-width="2.5" marker-end="url(#arrow9)"/><text x="293" y="60" text-anchor="middle" fill="#2dd4bf" font-size="10" font-family="system-ui" font-weight="bold">Migrate</text><rect x="355" y="35" width="180" height="70" rx="8" fill="none" stroke="#6366f1" stroke-width="2"/><rect x="365" y="45" width="160" height="15" rx="3" fill="#6366f1" opacity="0.7"/><rect x="365" y="65" width="160" height="15" rx="3" fill="#a855f7" opacity="0.7"/><rect x="365" y="85" width="100" height="10" rx="2" fill="#2dd4bf" opacity="0.5"/><text x="445" y="57" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Bare Metal</text><text x="445" y="77" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Docker + LXC</text><text x="445" y="120" text-anchor="middle" fill="#94a3b8" font-size="9" font-family="system-ui">$200/mo</text><text x="300" y="150" text-anchor="middle" fill="#2dd4bf" font-size="11" font-family="system-ui" font-weight="bold">96% cost reduction</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Cloud to self-hosted migration can dramatically reduce infrastructure costs while maintaining full control.</p></div>
Upload Provider — Local with Backup
For self-hosted file uploads, configure the local provider with a persistent volume:
// config/plugins.ts
export default ({ env }) => ({
upload: {
config: {
providerOptions: {
localServer: {
maxage: 300000,
},
},
sizeLimit: 50 * 1024 * 1024, // 50MB
breakpoints: {
xlarge: 1920,
large: 1000,
medium: 750,
small: 500,
xsmall: 64,
},
},
},
});Automated Backups
Create a backup script that dumps both the database and uploaded files:
#!/bin/bash
# backup-strapi.sh
BACKUP_DIR="/backups/strapi"
DATE="\$(date +%Y%m%d_%H%M%S)"
mkdir -p "\$BACKUP_DIR"
# Database dump
docker exec strapi-db pg_dump -U strapi strapi | \
gzip > "\$BACKUP_DIR/db_\$DATE.sql.gz"
# Upload files
tar czf "\$BACKUP_DIR/uploads_\$DATE.tar.gz" \
./strapi-app/public/uploads/
# Keep last 30 days
find "\$BACKUP_DIR" -name "*.gz" -mtime +30 -delete
echo "Backup completed: \$DATE"Add it to cron:
# Run daily at 2 AM
0 2 * * * /opt/scripts/backup-strapi.sh >> /var/log/strapi-backup.log 2>&1Performance Tuning
PostgreSQL Tuning
For a server with 4GB RAM dedicated to the database:
ALTER SYSTEM SET shared_buffers = '1GB';
ALTER SYSTEM SET effective_cache_size = '3GB';
ALTER SYSTEM SET work_mem = '16MB';
ALTER SYSTEM SET maintenance_work_mem = '256MB';
ALTER SYSTEM SET random_page_cost = 1.1; -- SSD storage
ALTER SYSTEM SET effective_io_concurrency = 200;
SELECT pg_reload_conf();Strapi Caching
Enable REST API caching with the strapi-plugin-rest-cache plugin to reduce database queries by 80-90% for read-heavy APIs.
Security Hardening
1. Change default admin URL: Set config/admin.ts to use a custom path 2. Rate limiting: Configure the built-in rate limiter 3. CORS: Restrict origins to your frontend domains 4. API tokens: Use scoped tokens instead of full-access tokens 5. Disable GraphQL playground in production: Set playgroundAlways: false
Monitoring
Add a health check endpoint and monitor it with Uptime Kuma:
# Strapi health check
curl -f http://localhost:1337/_health || exit 1<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 190" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="190" rx="12" fill="#0d1117"/><rect x="0" y="0" width="600" height="28" rx="12" fill="#1c2333"/><rect x="0" y="12" width="600" height="16" fill="#1c2333"/><circle cx="18" cy="14" r="5" fill="#ef4444"/><circle cx="34" cy="14" r="5" fill="#f59e0b"/><circle cx="50" cy="14" r="5" fill="#2dd4bf"/><text x="300" y="18" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="monospace">Terminal</text><text x="20" y="50" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><text x="35" y="50" fill="#e2e8f0" font-size="11" font-family="monospace">docker compose up -d</text><text x="20" y="70" fill="#94a3b8" font-size="11" font-family="monospace">[+] Running 5/5</text><text x="20" y="88" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="88" fill="#94a3b8" font-size="10" font-family="monospace">Network app_default Created</text><text x="20" y="106" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="106" fill="#94a3b8" font-size="10" font-family="monospace">Container web Started</text><text x="20" y="124" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="124" fill="#94a3b8" font-size="10" font-family="monospace">Container api Started</text><text x="20" y="142" fill="#2dd4bf" font-size="10" font-family="monospace"> ✓</text><text x="38" y="142" fill="#94a3b8" font-size="10" font-family="monospace">Container db Started</text><text x="20" y="165" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><rect x="35" y="155" width="8" height="14" fill="#e2e8f0" opacity="0.7"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Docker Compose brings up your entire stack with a single command.</p></div>
TechSaaS Deployment Pattern
We use Traefik as a reverse proxy in front of Strapi, which handles SSL termination and routes traffic based on hostname. Combined with Authelia for admin panel protection, this gives you enterprise-grade security on a self-hosted setup.
Our typical Strapi deployment serves 10,000+ API requests per minute on a 2-core, 4GB RAM allocation — far exceeding what most projects need.
Need help deploying Strapi or any headless CMS? Reach out at [email protected].
Need help with self-hosted?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.