← All articlesSelf-Hosted

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.

Y
Yash Pritwani
14 min read

One-field diagnostic start

Send one work email. Yash replies with the matching service path, first evidence step, and owner handoff for this issue.

No calendar step. The full contact form stays available if you want to add system context.

One owner, one affected system, and the next buyer or recovery deadline mapped.

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

A Linux server (Ubuntu 22.04+ or Debian 12+)
Docker and Docker Compose installed
PostgreSQL 14+ (or use the Docker setup below)
Node.js 20 LTS (for local development)
A domain name with DNS configured

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-run

Then 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>&1

Performance 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"> &#x2713;</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"> &#x2713;</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"> &#x2713;</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"> &#x2713;</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].

#strapi#headless-cms#self-hosted#postgresql#docker

Need the next owner and evidence step mapped?

Send the current system and deadline. Yash replies with the service path, first proof artifact, and handoff owner.