Skip to Content

Production Guide: Deploy Strapi with Docker Compose + Caddy + PostgreSQL on Ubuntu

Self-host your headless CMS with automatic HTTPS, environment-variable secrets, and a repeatable upgrade path

Most content teams end up fighting their CMS instead of shipping content. Traditional platforms lock you into rigid templates, charge scaling fees, and make it painful to push structured content to mobile apps, static sites, or multiple front-ends at once. Strapi solves this at the architecture level: it is a fully open-source headless CMS that exposes all your content types via a configurable REST or GraphQL API, lets you define custom content types through a browser-based UI, and gives you a role-based permission system that is actually usable in production. You own the database, you own the API, and you run the entire stack on your own infrastructure.

This guide walks you through a production-ready Strapi deployment on Ubuntu using Docker Compose for container orchestration, Caddy as the TLS-terminating reverse proxy, and PostgreSQL as the primary database. At the end you will have a secure, auto-HTTPS Strapi instance that your front-end teams can query over HTTPS, with environment-variable-driven secrets and a repeatable upgrade path.

Architecture and flow overview

The production stack uses three containers behind a single Caddy reverse proxy running on the host network:

  • strapi — the Node.js application container, listening internally on port 1337
  • db — PostgreSQL 15 with a named volume for durable data storage
  • caddy — Caddy 2 running in network_mode: host to own ports 80 and 443, automatically obtaining and renewing Let's Encrypt certificates

Caddy terminates TLS and reverse-proxies HTTPS requests to the Strapi container on the internal Docker bridge network. All application secrets are stored in a single .env file and injected as environment variables at runtime. Persistent data lives in Docker-managed named volumes so container rebuilds and upgrades do not lose your content, media uploads, or database records. Strapi's built-in admin panel is served at /admin while the REST API is accessible at /api — both over the same HTTPS domain.

Prerequisites

  • Ubuntu 22.04 or 24.04 server with at least 2 vCPU and 4 GB RAM (Strapi's Node.js build process is memory-intensive; 2 GB can stall on first build)
  • Docker Engine 24+ and the Docker Compose plugin installed (apt install docker.io docker-compose-plugin)
  • Caddy 2 installed as a system service from the official Caddy apt repository
  • A DNS A record pointing your chosen subdomain (e.g. cms.yourdomain.com) to the server's public IP
  • UFW or equivalent firewall with inbound ports 80 and 443 open
  • Root or sudo access on the server

Step-by-step deployment

1. Prepare the project directory

Create an isolated directory and restrict permissions before adding any secret files:

mkdir -p /opt/strapi && cd /opt/strapi
chmod 700 /opt/strapi

2. Generate secrets

Strapi requires four cryptographically distinct secrets. Generate each one now and save the values before writing the environment file:

openssl rand -base64 32   # APP_KEYS (run 4 times, comma-separate the results)
openssl rand -hex 32      # API_TOKEN_SALT
openssl rand -hex 32      # ADMIN_JWT_SECRET
openssl rand -hex 32      # TRANSFER_TOKEN_SALT

3. Create the environment file

Create /opt/strapi/.env with the generated values and your database credentials:

# PostgreSQL
POSTGRES_DB=strapi
POSTGRES_USER=strapi
POSTGRES_PASSWORD=change_this_strong_password

# Strapi database connection
DATABASE_CLIENT=postgres
DATABASE_HOST=db
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=change_this_strong_password
DATABASE_SSL=false

# Strapi application secrets (replace with generated values)
APP_KEYS=key1==,key2==,key3==,key4==
API_TOKEN_SALT=paste_hex_here
ADMIN_JWT_SECRET=paste_hex_here
JWT_SECRET=paste_hex_here
TRANSFER_TOKEN_SALT=paste_hex_here

# App URL
PUBLIC_URL=https://cms.yourdomain.com
chmod 600 /opt/strapi/.env

4. Create the Docker Compose file

Create /opt/strapi/docker-compose.yml:

version: "3.9"

services:
  db:
    image: postgres:15-alpine
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - strapi_db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 15s
      timeout: 5s
      retries: 5

  strapi:
    image: strapi/strapi:latest
    restart: unless-stopped
    env_file: .env
    ports:
      - "127.0.0.1:1337:1337"
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - strapi_uploads:/opt/app/public/uploads
      - strapi_config:/opt/app/config

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    network_mode: host
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  strapi_db:
  strapi_uploads:
  strapi_config:
  caddy_data:
  caddy_config:

5. Create the Caddyfile

Create /opt/strapi/Caddyfile to configure automatic HTTPS and reverse proxy to the Strapi container:

cms.yourdomain.com {
    reverse_proxy 127.0.0.1:1337
}

Caddy automatically handles certificate provisioning and HTTP-to-HTTPS redirects. No additional TLS configuration is needed.

6. Start the stack

Pull images and start all containers. The first startup includes Strapi's build step, which may take two to three minutes depending on server resources:

cd /opt/strapi
docker compose pull
docker compose up -d
docker compose logs -f strapi

Wait until you see Strapi is ready in the logs before proceeding. If the Strapi container restarts with out-of-memory errors, add a mem_limit: 2g constraint or increase your server RAM.

7. Create the initial admin account

Open https://cms.yourdomain.com/admin in your browser. On first load, Strapi presents a registration form. Create the administrator account and log in. All subsequent admin access is protected by the credentials you set here. Record the credentials in your password manager before closing the tab.

Configuration and secrets handling

The .env file is the single source of truth for all runtime secrets. Key practices for a hardened production deployment:

  • Never commit .env to version control. Add it to .gitignore if you track the Compose file in a repository. Use a secrets manager (Vault, AWS Secrets Manager, or Infisical) for team environments.
  • Rotate APP_KEYS periodically. Strapi signs cookies and session tokens with APP_KEYS. Rotation invalidates all active admin sessions — schedule it during a maintenance window and notify admins before rotating.
  • Restrict the Strapi API surface. By default, all content types return public data if the public role has permission. Review Settings → Roles → Public in the admin panel immediately after first login and remove any permissions you did not intentionally grant.
  • API token scoping. When issuing API tokens for front-end applications, prefer scoped tokens (read-only, single content type) over full-access tokens. Tokens are managed at Settings → API Tokens.
  • Database access. The PostgreSQL container is not exposed outside the Docker bridge network. Avoid adding host-port mappings to the db service; access the database for maintenance through docker compose exec db psql -U strapi.

Verification

After startup, confirm the stack is healthy with the following checks:

# All containers should show "Up" or "healthy"
docker compose -f /opt/strapi/docker-compose.yml ps

# Caddy should respond with the Strapi HTML shell
curl -I https://cms.yourdomain.com

# Admin panel should be reachable
curl -I https://cms.yourdomain.com/admin

# Health endpoint returns JSON status
curl -s https://cms.yourdomain.com/_health | python3 -m json.tool

Expected: all three curl calls return HTTP 200. The /_health response should include "status": "ok". If the admin returns 502, Caddy is running but Strapi has not finished building — check docker compose logs strapi again.

Common issues and fixes

  • Strapi container exits with Exit 1 on first start: The most common cause is a missing or malformed APP_KEYS value. Each key must be a valid base64 string; an improperly generated key causes Strapi's startup validation to fail. Regenerate all four keys with openssl rand -base64 32, update .env, and restart with docker compose up -d --force-recreate strapi.
  • Out-of-memory crash during admin panel build: Strapi builds the admin panel React bundle on first run. This requires approximately 1.5 GB of RAM. If the process is killed, add a swap file: fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile. Then restart the stack.
  • Caddy returning 502 after startup: Strapi's build phase can take several minutes. Caddy is already listening on 443 and proxying to port 1337, but Strapi is not yet accepting connections. Wait for the Strapi is ready log line. If it never appears, check PostgreSQL health: docker compose logs db.
  • Media uploads failing: If uploads return 413 errors through Caddy, add a request_body { max_size 50mb } directive inside the Caddyfile site block. If uploads succeed but files vanish after restart, confirm the strapi_uploads volume is correctly mounted — orphan volumes can occur after a docker compose down -v.
  • Database connection refused after server reboot: Ensure Docker is configured to start on boot (systemctl enable docker) and that the Compose project uses restart: unless-stopped on all services. The healthcheck on the db service prevents Strapi from attempting connections before PostgreSQL is ready.

FAQ

Can I run Strapi with SQLite instead of PostgreSQL?

Yes, Strapi supports SQLite and it is the default for development. For production, PostgreSQL is strongly recommended: SQLite does not handle concurrent API writes well and can corrupt under heavy load. SQLite also lacks row-level locking, which causes problems as your content team grows. If you are migrating from an existing SQLite-based Strapi instance, Strapi's built-in Data Transfer feature can export and import your content to a PostgreSQL target.

How do I keep Strapi updated without losing content?

Because all persistent data lives in Docker named volumes, updating the Strapi image is safe. Pull the new image tag, stop the stack, and restart: docker compose pull strapi && docker compose up -d --force-recreate strapi. Before major version upgrades (e.g. v4 to v5), review Strapi's official migration guide and take a PostgreSQL dump first: docker compose exec db pg_dump -U strapi strapi > /opt/strapi/backup-$(date +%Y%m%d).sql.

How do I connect a Next.js or Nuxt.js front-end to this Strapi instance?

Set the NEXT_PUBLIC_STRAPI_URL (or equivalent) environment variable in your front-end application to https://cms.yourdomain.com. Use a scoped read-only API token generated at Settings → API Tokens for public content fetching. For user-authenticated content, Strapi's Users & Permissions plugin handles JWT-based auth out of the box — pass the token in the Authorization: Bearer <token> header.

Can I enable GraphQL instead of REST?

Yes. Install the official GraphQL plugin from inside the container: docker compose exec strapi npm install @strapi/plugin-graphql, then rebuild the admin panel with docker compose exec strapi npm run build and restart. The GraphQL playground is available at https://cms.yourdomain.com/graphql. Note that GraphQL queries require the same role-based permissions as REST — grant the Public or Authenticated role access to each content type you want to expose.

How do I add S3-compatible storage for media uploads?

Install the @strapi/provider-upload-aws-s3 plugin (or the compatible provider for MinIO, Cloudflare R2, or Backblaze B2) and configure it in /opt/strapi/strapi_config/plugins.js. Add the bucket credentials to .env and reference them in the plugin config. After this change, all new uploads go directly to object storage, which eliminates volume size concerns and survives container replacements without manual backup of the uploads volume.

What is the recommended backup strategy for this stack?

A reliable daily backup covers two things: the PostgreSQL database and the uploads volume. For the database, create a cron job that runs docker compose exec -T db pg_dump -U strapi strapi | gzip > /backups/strapi-$(date +%Y%m%d).sql.gz and ships the file to S3 or another off-server destination. For uploads, use docker run --rm --volumes-from strapi_strapi_1 -v /backups:/backup alpine tar czf /backup/uploads-$(date +%Y%m%d).tar.gz /opt/app/public/uploads. Test restore procedures quarterly so backup validity is confirmed before you need them.

How do I enable Single Sign-On for the Strapi admin panel?

SSO for the Strapi admin panel requires the Enterprise edition. For open-source deployments, the recommended approach is to front the admin panel with an SSO-aware reverse proxy (such as Authentik or Authelia in forward-auth mode with Caddy) to add an authentication wall in front of /admin. Regular content API users authenticate through Strapi's built-in JWT tokens, which work independently of admin SSO.

Internal links

Talk to us

If you want this deployed with hardened access controls, monitoring standards, and production runbooks tailored to your environment, our team can help end-to-end.

Contact Us

Production Guide: Deploy Leantime with Docker Compose + Caddy + MySQL on Ubuntu
Self-host an ADHD-friendly project management platform with full data ownership, automatic TLS, and a production-hardened MySQL backend