Directus is an open-source headless CMS that sits on top of a SQL database and exposes its tables through REST and GraphQL APIs, plus a web app (the Data Studio) for editors. In this tutorial you will deploy Directus on Ubuntu 24.04 with Docker Compose, using PostgreSQL for data and Redis for caching, publish it with Nginx and HTTPS, and then configure the parts that matter in production: access policies, Flows automation, image transformations and database backups.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS with at least 2 GB of RAM, for example a CubePath VPS.
- A non-root user with
sudoprivileges. - Docker Engine and the Docker Compose plugin installed from Docker's official repository.
- Nginx installed, with UFW allowing SSH and the
Nginx Fullprofile. - A subdomain (this guide uses
cms.your_domain) with a DNSArecord pointing to your server.
Step 1 - Preparing the project directory and secrets
Keep the Compose file and its secrets in one directory owned by your user:
mkdir -p ~/directus
cd ~/directus
Directus needs a SECRET to sign access tokens, and PostgreSQL needs a password. Generate both randomly:
openssl rand -base64 36
openssl rand -hex 24
Create a .env file. Docker Compose reads it automatically and substitutes the variables into docker-compose.yml:
nano .env
DIRECTUS_SECRET=paste_the_first_random_value
DB_PASSWORD=paste_the_second_random_value
ADMIN_EMAIL=admin@your_domain
ADMIN_PASSWORD=your_strong_password
PUBLIC_URL=https://cms.your_domain
Restrict the file, since it contains credentials:
chmod 600 .env
ADMIN_EMAIL and ADMIN_PASSWORD are only used on the very first start, when Directus bootstraps an empty database. Changing them later has no effect.
Step 2 - Writing the Docker Compose file
The stack has three services: PostgreSQL, Redis and Directus. Only Directus publishes a port, and only on 127.0.0.1, so it is reachable exclusively through Nginx.
nano docker-compose.yml
services:
database:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: directus
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: directus
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U directus -d directus"]
interval: 10s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
directus:
image: directus/directus:latest
restart: unless-stopped
ports:
- "127.0.0.1:8055:8055"
depends_on:
database:
condition: service_healthy
cache:
condition: service_healthy
environment:
SECRET: ${DIRECTUS_SECRET}
PUBLIC_URL: ${PUBLIC_URL}
DB_CLIENT: pg
DB_HOST: database
DB_PORT: 5432
DB_DATABASE: directus
DB_USER: directus
DB_PASSWORD: ${DB_PASSWORD}
CACHE_ENABLED: "true"
CACHE_STORE: redis
CACHE_AUTO_PURGE: "true"
REDIS: redis://cache:6379
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
ASSETS_TRANSFORM_MAX_CONCURRENT: 4
ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION: 6000
volumes:
- uploads:/directus/uploads
- extensions:/directus/extensions
volumes:
db_data:
uploads:
extensions:
A few settings deserve explanation:
CACHE_AUTO_PURGEclears cached API responses whenever content changes, so editors do not see stale data.REDISis also used for rate limiting and for synchronizing state if you later run several Directus containers.- The
ASSETS_TRANSFORM_*variables cap how many image transformations run in parallel and the largest image dimension Directus will process, which protects memory on a small server.
Tip
latestis convenient for a first install. For production, replace it with a specific version tag from the Directus releases page so thatdocker compose pullnever upgrades you by surprise.
Step 3 - Starting the stack
Pull the images and start the containers in the background:
docker compose up -d
On the first start Directus creates its system tables and the admin user. Follow the logs until the server reports it is listening:
docker compose logs -f directus
directus-1 | [..] INFO: Server started at http://0.0.0.0:8055
Press Ctrl+C to stop following the logs. Confirm all three services are healthy and the API answers:
docker compose ps
curl -s http://127.0.0.1:8055/server/ping
pong
Step 4 - Publishing Directus with Nginx and HTTPS
Create a server block that forwards traffic to the container:
sudo nano /etc/nginx/sites-available/directus
server {
listen 80;
listen [::]:80;
server_name cms.your_domain;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:8055;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
client_max_body_size must be at least as large as the biggest file editors will upload. The Upgrade headers allow Directus's WebSocket features if you enable them later.
Enable the site and obtain a certificate:
sudo ln -s /etc/nginx/sites-available/directus /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d cms.your_domain
Open https://cms.your_domain and sign in with ADMIN_EMAIL and ADMIN_PASSWORD. Once you are in, remove ADMIN_PASSWORD from .env; it is no longer used.
Step 5 - Creating a collection and an API token
To have something to configure, create a content model. In the Data Studio, go to Settings > Data Model, click Create Collection, name it articles and keep the default primary key. Enable the optional Status field when offered, then add:
title: Input, string, required.body: WYSIWYG.cover: Image.
Scripts and servers should not use your admin password. Open your user in User Directory, scroll to the Token field, generate a static token, copy it and save the user. Then test the API from the server, replacing your_token:
curl -s https://cms.your_domain/items/articles -H "Authorization: Bearer your_token"
{"data":[]}
Step 6 - Configuring access with roles and policies
Since Directus 11, permissions are grouped into access policies, and policies are attached to roles or directly to users. A role is only a label for a group of people; what they can do comes from its policies.
Create a policy for editors who write drafts but cannot publish:
- Go to Settings > Access Policies and create a policy named
Article editors. Enable App Access so its users can sign in to the Data Studio. - In the Permissions section, add
articlesand set:- Read: All access.
- Create: Use custom. Under Field Validation, add the rule
statusequalsdraft, so new items can only be drafts. - Update: Use custom. Under Item Permissions, add
user_createdequals$CURRENT_USER, so editors only change their own items. Under Field Permissions, uncheckstatus. - Delete: No access.
- Also grant Read on
directus_filesand Create ondirectus_filesso editors can upload cover images. - Go to Settings > User Roles, create a role named
Editorand attach theArticle editorspolicy to it.
To expose published articles to anonymous visitors, open the built-in Public policy and give it Read on articles with the item rule status equals published. Verify it without a token:
curl -s "https://cms.your_domain/items/articles?fields=id,title,status"
Only items whose status is published are returned. Draft items stay invisible until someone with the admin policy publishes them.
Step 7 - Automating with Flows
Flows are Directus's built-in automation engine and replace the webhooks feature that older versions had. A flow has one trigger and a chain of operations.
This example calls a frontend's revalidation endpoint whenever an article is published:
- Go to Settings > Flows and create a flow named
Revalidate site. - Choose the Event Hook trigger, type Action (Non-Blocking), scope
items.createanditems.update, collectionarticles. - Add a Condition operation with this rule, so the flow continues only for published items:
{
"$trigger": {
"payload": {
"status": {
"_eq": "published"
}
}
}
}
- On the condition's success path, add a Webhook / Request URL operation: method
POST, URLhttps://your_frontend_domain/api/revalidate, a headerx-revalidate-secretwith your shared secret, and the body:
{
"collection": "{{$trigger.collection}}",
"keys": "{{$trigger.keys}}"
}
Save the flow, publish an article and open the flow's Logs panel in the sidebar. Each run shows the trigger payload and the response of the request, which is the fastest way to debug a flow.
Flows can also run on a Schedule (CRON) trigger, for example 0 */6 * * * to sync data every six hours, or be exposed as a Webhook trigger that external systems call.
Step 8 - Serving transformed images
Directus resizes and converts images on the fly through query parameters on the /assets endpoint. Upload an image in File Library, copy its ID and request a WebP thumbnail:
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
"https://cms.your_domain/assets/your_file_id?width=800&height=450&fit=cover&format=webp&quality=80" \
-H "Authorization: Bearer your_token"
200 image/webp
fit accepts cover, contain, inside and outside, and format accepts jpg, png, webp, tiff and avif. Transformed images are cached in the uploads volume, so only the first request pays the processing cost. To stop clients from requesting arbitrary sizes, define named Transformation Presets in the project settings (Files & Storage section) and request them with ?key=preset_name.
Step 9 - Backing up the database and uploads
The database and the uploads volume together are your whole CMS. Dump PostgreSQL from the running container into a compressed file:
mkdir -p ~/directus/backups
docker compose exec -T database pg_dump -U directus -Fc directus > ~/directus/backups/directus-$(date +%F).dump
ls -lh ~/directus/backups
Archive the uploads volume with a throwaway container:
docker run --rm -v directus_uploads:/data:ro -v ~/directus/backups:/backup alpine \
tar czf /backup/uploads-$(date +%F).tar.gz -C /data .
The volume name is the project directory name plus the volume key; confirm it with docker volume ls. Copy both files off the server with your usual backup tool. To restore the database into an empty stack, use pg_restore:
docker compose exec -T database pg_restore -U directus -d directus --clean --if-exists < ~/directus/backups/directus-YYYY-MM-DD.dump
To move your data model (not the content) between environments, export a schema snapshot and apply it on the other instance with npx directus schema apply:
docker compose exec directus npx directus schema snapshot /directus/uploads/snapshot.yaml
Troubleshooting
The container restarts in a loop. Run docker compose logs directus. Missing SECRET or wrong database credentials are the usual causes. If you changed DB_PASSWORD after the first start, PostgreSQL still has the old one, because it is only applied when the volume is initialized.
Login works but redirects to localhost. PUBLIC_URL does not match the address in the browser. Set it to https://cms.your_domain and run docker compose up -d to recreate the container.
Uploads fail with 413. Nginx is rejecting the body. Increase client_max_body_size and reload Nginx.
An editor gets a 403 on an item they should see. Check every policy attached to the user's role. Permissions from multiple policies are combined, so a missing field permission on one collection, such as directus_files for images, is often the cause.
Conclusion
Directus is now running on Ubuntu 24.04 with PostgreSQL, Redis caching and HTTPS, with access policies separating editors from the public, a flow that notifies your frontend on publish, and a backup routine for the database and files. Next, you could store uploads in S3-compatible object storage with the STORAGE_LOCATIONS variables, configure SMTP so Directus can send password reset emails, or build custom endpoints and hooks as extensions in the extensions volume.
