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 dockerStep-by-step deployment
1. Create the project directory and secrets file
mkdir -p ~/directus && cd ~/directus
touch .env && chmod 600 .env2. 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
EOFGenerate 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
}
EOF4. 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 directusDirectus 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" >> .gitignoreFor 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_secretMount 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/uploadsVerification
# 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 browserA 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
healthcheckblock to the postgres service and usecondition: service_healthyunder directusdepends_on. Withrestart: 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 directusfor migration errors. Confirm theDIRECTUS_KEYandDIRECTUS_SECRETare 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. Checkdocker compose logs caddyfor 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/uploadsto 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 directusHow 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
- Production Guide: Deploy Infisical with Docker Compose + Caddy + PostgreSQL + Redis on Ubuntu
- Production Guide: Deploy Gitea with Docker Compose + Caddy on Ubuntu
- Production Guide: Deploy Vaultwarden with Docker Compose + Caddy on Ubuntu
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.