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 sudo privileges.
  • Docker Engine and the Docker Compose plugin installed from Docker's official repository.
  • Nginx installed, with UFW allowing SSH and the Nginx Full profile.
  • A subdomain (this guide uses cms.your_domain) with a DNS A record 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_PURGE clears cached API responses whenever content changes, so editors do not see stale data.
  • REDIS is 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.

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:

  1. 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.
  2. In the Permissions section, add articles and set:
    • Read: All access.
    • Create: Use custom. Under Field Validation, add the rule status equals draft, so new items can only be drafts.
    • Update: Use custom. Under Item Permissions, add user_created equals $CURRENT_USER, so editors only change their own items. Under Field Permissions, uncheck status.
    • Delete: No access.
  3. Also grant Read on directus_files and Create on directus_files so editors can upload cover images.
  4. Go to Settings > User Roles, create a role named Editor and attach the Article editors policy 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:

  1. Go to Settings > Flows and create a flow named Revalidate site.
  2. Choose the Event Hook trigger, type Action (Non-Blocking), scope items.create and items.update, collection articles.
  3. Add a Condition operation with this rule, so the flow continues only for published items:
{
  "$trigger": {
    "payload": {
      "status": {
        "_eq": "published"
      }
    }
  }
}
  1. On the condition's success path, add a Webhook / Request URL operation: method POST, URL https://your_frontend_domain/api/revalidate, a header x-revalidate-secret with 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.