Skip to Content

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

A complete production walkthrough: Directus headless CMS, PostgreSQL, and Caddy with automatic TLS on Ubuntu.

Directus is an open-source headless CMS and data platform that wraps any SQL database with a powerful REST and GraphQL API, a no-code data studio, and a robust role-based access control system. Engineering teams use it to accelerate API-driven product development without the overhead of bespoke backend scaffolding. In this guide you will deploy a production-ready Directus instance backed by PostgreSQL, fronted by a Caddy reverse proxy with automatic TLS, and managed via Docker Compose on Ubuntu - the same stack used in real-world SaaS and enterprise integrations.

This guide targets teams that need a battle-tested self-hosted alternative to Contentful or Strapi without vendor lock-in. Directus stores your data in a standard PostgreSQL schema you own and can query directly, making migrations and integrations straightforward. The Caddy reverse proxy handles HTTPS termination automatically using ACME so there are no manual certificate renewals. The entire stack runs as stateless containers except for named volumes for the database and file uploads, which makes horizontal scaling and disaster recovery predictable.

Architecture and flow overview

Three containers work together inside a single Compose project:

  • postgres - PostgreSQL 16, data-only volume, no host port exposure.
  • directus - Node.js application, bound only to the internal Docker network on port 8055.
  • caddy - Caddy 2 listening on host ports 80 and 443; provisions a Let's Encrypt TLS certificate and reverse-proxies HTTPS traffic to the Directus container on the internal network.

No credentials or database ports are exposed to the public internet. Caddy uses its built-in ACME client so you need zero manual certificate management. All inter-service communication happens on an isolated Docker bridge network named internal, which means Postgres and Directus are not reachable from the host or the internet directly.

Prerequisites

  • Ubuntu 22.04 or 24.04 server with a public IP
  • A DNS A record pointing your domain (e.g. cms.example.com) to the server IP
  • Docker Engine 24+ and Docker Compose v2 installed
  • Ports 80 and 443 open in your firewall or security group
  • Non-root sudo user (recommended)
sudo apt update && sudo apt install -y docker.io docker-compose-plugin
sudo usermod -aG docker $USER
newgrp docker

Step-by-step deployment

1. Create the project directory and secrets file

mkdir -p ~/directus && cd ~/directus
touch .env && chmod 600 .env

2. Populate the .env file

cat > .env << 'EOF'
# PostgreSQL
POSTGRES_USER=directus
POSTGRES_PASSWORD=changeme_db_pass
POSTGRES_DB=directus

# Directus
DIRECTUS_KEY=change-me-32-char-random-key-here
DIRECTUS_SECRET=change-me-64-char-random-secret-here
[email protected]
ADMIN_PASSWORD=changeme_admin_pass

# Domain
DOMAIN=cms.example.com
EOF

Generate strong random values with:

openssl rand -hex 16   # for DIRECTUS_KEY (32 chars)
openssl rand -hex 32   # for DIRECTUS_SECRET (64 chars)

3. Create the Caddyfile

cat > Caddyfile << 'EOF'
{$DOMAIN} {
    reverse_proxy directus:8055
}
EOF

4. Create the Docker Compose file

version: "3.9"

services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - internal

  directus:
    image: directus/directus:latest
    restart: unless-stopped
    depends_on:
      - postgres
    env_file: .env
    environment:
      KEY: ${DIRECTUS_KEY}
      SECRET: ${DIRECTUS_SECRET}
      DB_CLIENT: pg
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_DATABASE: ${POSTGRES_DB}
      DB_USER: ${POSTGRES_USER}
      DB_PASSWORD: ${POSTGRES_PASSWORD}
      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
      PUBLIC_URL: https://${DOMAIN}
    networks:
      - internal

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    environment:
      DOMAIN: ${DOMAIN}
    networks:
      - internal

networks:
  internal:
    driver: bridge

volumes:
  postgres_data:
  caddy_data:
  caddy_config:

5. Start the stack

docker compose up -d
docker compose logs -f directus

Directus typically takes 30-60 seconds to run its database migrations on first boot. Wait until you see Server started at http://0.0.0.0:8055 in the logs before testing. The PostgreSQL container must be fully initialized before Directus starts. Docker Compose will restart the Directus service automatically if it exits early during the first boot race condition.

Configuration and secrets handling

Keep the .env file out of version control at all times. Add it to .gitignore immediately after creating it:

echo ".env" >> .gitignore

For team environments or CI/CD pipelines, replace plain-text secrets with a secrets manager. Directus supports *__FILE suffixes for all sensitive environment variables, allowing you to pass file paths to secrets instead of plain values. For example:

    environment:
      DB_PASSWORD__FILE: /run/secrets/db_pass
      DIRECTUS_SECRET__FILE: /run/secrets/directus_secret

Mount the actual secrets files from Docker Secrets or a volume. This keeps credentials out of the Docker inspect output and container environment entirely. For HashiCorp Vault or Infisical, use their respective sidecar agents to inject secrets as files at container startup rather than environment variables.

Set your uploads directory as a named volume so uploaded files persist across container restarts and image upgrades:

    volumes:
      - uploads_data:/directus/uploads

Verification

# Check all containers are running
docker compose ps

# Verify Directus health endpoint
curl -s https://cms.example.com/server/health | python3 -m json.tool

# Confirm TLS certificate is valid
curl -vI https://cms.example.com 2>&1 | grep -E "SSL|issuer|expire"

# Log in to the admin panel
# Open https://cms.example.com/admin in your browser

A healthy response from /server/health looks like:

{"status": "ok", "releaseId": "11.x.x", "checks": {}}

Common issues and fixes

  • Directus fails to start with ECONNREFUSED connecting to postgres - The database container may still be initialising. Add a healthcheck block to the postgres service and use condition: service_healthy under directus depends_on. With restart: unless-stopped, Docker Compose will retry automatically until Postgres is ready.
  • Caddy returns 502 Bad Gateway - Directus is not yet listening. Check docker compose logs directus for migration errors. Confirm the DIRECTUS_KEY and DIRECTUS_SECRET are non-empty strings; a missing key causes a silent startup abort with no obvious error message.
  • TLS certificate not issued - Verify port 80 is open and the DNS A record resolves to the server IP (dig +short cms.example.com). Caddy requires port 80 for the ACME HTTP-01 challenge. Check docker compose logs caddy for ACME error messages.
  • Migrations fail on upgrade - Always back up PostgreSQL before upgrading Directus versions. After a failed migration, restore the backup and pin the previous image version in Compose before investigating the root cause.
  • File uploads not persisting across restarts - Add - uploads_data:/directus/uploads to the directus service volumes and declare the named volume at the top-level volumes block. Without this, all uploaded files are lost when the container is recreated.

FAQ

Can I use MySQL or MariaDB instead of PostgreSQL with Directus?

Yes. Set DB_CLIENT=mysql or DB_CLIENT=mysql2 and adjust the database environment variables accordingly. PostgreSQL is recommended for production because it has better support for Directus JSON field types, full-text search extensions, and is the database most thoroughly tested by the Directus core team.

How do I enable email invitations and password resets?

Set SMTP environment variables in .env: EMAIL_FROM, EMAIL_TRANSPORT=smtp, EMAIL_SMTP_HOST, EMAIL_SMTP_PORT, EMAIL_SMTP_USER, and EMAIL_SMTP_PASSWORD. Restart the directus container after updating. Directus will then send invitation and password-reset emails through your SMTP relay.

How do I keep Directus up to date safely?

Pin a specific version tag such as directus/directus:11.2.0 instead of latest in production. To upgrade: update the tag in docker-compose.yml, run docker compose pull directus, then docker compose up -d directus. Always back up PostgreSQL before upgrading major versions and review the Directus changelog for breaking changes.

Can multiple developers share the same Directus instance safely?

Yes. Use Directus's built-in Roles and Permissions system to scope each user to the collections and actions they need. For local development, each developer should run their own local Directus stack pointed at a local database rather than sharing a staging instance, to avoid migration conflicts and accidental data changes.

How do I back up and restore the database?

# Backup
docker compose exec postgres pg_dump -U directus directus > backup_$(date +%F).sql

# Restore
cat backup_2026-06-01.sql | docker compose exec -T postgres psql -U directus directus

How do I add custom extensions or hooks?

Mount a local extensions directory into the Directus container by adding - ./extensions:/directus/extensions to the volumes block. Directus will auto-discover endpoints, hooks, interfaces, displays, layouts, and modules placed in the appropriate subdirectory. Restart the container after adding new extensions for them to take effect.

How do I restrict Directus to certain IP addresses or add rate limiting?

Add IP filtering or rate limiting rules directly in your Caddyfile using Caddy's remote_ip matcher and the rate_limit module. Alternatively, place a firewall rule (UFW or cloud security group) in front of the server, allowing only trusted CIDR ranges on port 443 when Directus is used internally.

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 Monica Personal CRM with Docker Compose + Caddy + MySQL on Ubuntu
Self-host your open-source personal CRM with automatic HTTPS, MySQL persistence, and production-ready reminder delivery