← 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

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 help with self-hosted?

TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.