Directus is an open-source headless CMS that sits on top of a SQL database and exposes your content through REST and GraphQL APIs, together with a web app (the Data Studio) for editors. In this tutorial you will run Directus 11 on Ubuntu 24.04 with Docker Compose, using PostgreSQL as the database and Redis as the cache. You will then put Nginx in front of it with a free Let's Encrypt certificate and create your first collection to test the API.

Prerequisites

To follow this guide 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.
  • A domain or subdomain (this guide uses cms.your_domain) with a DNS A record pointing to your server's public IP.
  • Ports 80 and 443 open in your firewall.

Step 1 - Creating the project directory and secrets

Keep everything related to this deployment in one directory, so the Compose file, the environment file and the backups live together:

sudo mkdir -p /opt/directus
sudo chown "$USER":"$USER" /opt/directus
cd /opt/directus

Directus needs a random SECRET to sign access tokens, and PostgreSQL needs a password. Generate both with openssl:

openssl rand -base64 32
openssl rand -hex 24

Copy the two values. Now create an .env file that Docker Compose will read automatically:

nano /opt/directus/.env

Paste the following, replacing the placeholders with your own values. Use the first random string for DIRECTUS_SECRET, the second one for DB_PASSWORD, and choose a strong password for the first administrator:

DIRECTUS_SECRET=paste_the_base64_value_here
DB_PASSWORD=paste_the_hex_value_here
ADMIN_EMAIL=admin@your_domain
ADMIN_PASSWORD=your_strong_admin_password
PUBLIC_URL=https://cms.your_domain

Restrict the file, since it contains credentials:

chmod 600 /opt/directus/.env

Step 2 - Writing the Docker Compose file

The stack has three services: PostgreSQL for data, Redis for caching and rate limiting, and Directus itself. Only Directus publishes a port, and only on 127.0.0.1, so it is reachable from Nginx on the same host but not directly from the internet.

Create the Compose file:

nano /opt/directus/compose.yaml
services:
  database:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: directus
      POSTGRES_USER: directus
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    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
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  directus:
    image: directus/directus:11
    restart: unless-stopped
    ports:
      - "127.0.0.1:8055:8055"
    volumes:
      - uploads:/directus/uploads
      - extensions:/directus/extensions
    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_AUTO_PURGE: "true"
      CACHE_STORE: redis
      REDIS: redis://cache:6379

      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}

volumes:
  db_data:
  uploads:
  extensions:

A few notes on this file:

  • The image is pinned to the 11 major version, so docker compose pull gets bug fixes and minor releases but never an unexpected major upgrade.
  • ADMIN_EMAIL and ADMIN_PASSWORD are only used on the very first start, when Directus bootstraps an empty database and creates the administrator account.
  • Named volumes are used for uploads and extensions. They avoid the permission problems you get with bind mounts, because the Directus container runs as the unprivileged node user.

Check that Compose parses the file and substitutes the variables:

docker compose config --quiet && echo "compose file OK"
compose file OK

Step 3 - Starting Directus

Pull the images and start the stack in the background:

cd /opt/directus
docker compose up -d

On the first start Directus creates its system tables in PostgreSQL and the admin user. Follow the logs until the server reports that it is listening:

docker compose logs -f directus
directus-1  | [..] INFO: Initializing bootstrap...
directus-1  | [..] INFO: Installing Directus system tables...
directus-1  | [..] INFO: Setting up first admin role...
directus-1  | [..] INFO: Adding first admin user...
directus-1  | [..] INFO: Server started at http://0.0.0.0:8055

Press CTRL+C to stop following the logs (the containers keep running). Confirm that all three services are up and that the API answers locally:

docker compose ps
curl http://127.0.0.1:8055/server/ping
pong

Step 4 - Configuring Nginx as a reverse proxy

Nginx will terminate TLS and forward requests to Directus. Install it:

sudo apt update
sudo apt install nginx

Create a server block for your subdomain:

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 controls the largest file editors can upload through the Data Studio; raise it if you store large media. The Upgrade headers let WebSocket connections through, which Directus uses if you enable its realtime API.

Enable the site, test the configuration and reload Nginx:

sudo ln -s /etc/nginx/sites-available/directus /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

If UFW is active, allow HTTP and HTTPS:

sudo ufw allow 'Nginx Full'

Step 5 - Enabling HTTPS with Let's Encrypt

Install Certbot and its Nginx plugin, then request a certificate. Certbot edits the server block to add TLS and an HTTP to HTTPS redirect:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d cms.your_domain

When it finishes, verify that the API responds over HTTPS and that automatic renewal works:

curl https://cms.your_domain/server/ping
sudo certbot renew --dry-run
pong

Open https://cms.your_domain in your browser and sign in with the ADMIN_EMAIL and ADMIN_PASSWORD from your .env file. Once you are in, change the password from your user profile and remove ADMIN_PASSWORD from .env; it is not used again after the first start.

Step 6 - Creating a collection and querying the API

In Directus, a collection is a database table and each field is a column. Create one to confirm the whole chain works:

  1. Go to Settings > Data Model and click Create Collection.
  2. Name it articles, keep the default id primary key and click Finish Setup.
  3. Add a Input field named title and a WYSIWYG field named body.
  4. Go to Content > Articles and create one item.

By default nothing is public. To let anonymous clients read articles, go to Settings > Access Policies, open Public, and grant the Read permission on articles. Then query the REST API:

curl https://cms.your_domain/items/articles
{"data":[{"id":1,"title":"Hello Directus","body":"<p>First article</p>"}]}

The same data is available through GraphQL at https://cms.your_domain/graphql. For private content, create a user or a static access token in the Data Studio and send it as an Authorization: Bearer <token> header instead of opening the Public policy.

Step 7 - Backing up and updating

Your content lives in the PostgreSQL volume and your files in the uploads volume. Dump the database with pg_dump running inside the container:

cd /opt/directus
docker compose exec -T database pg_dump -U directus -Fc directus > "directus-$(date +%F).dump"
ls -lh directus-*.dump

Copy the dump off the server together with the contents of the uploads volume. To update Directus within version 11, pull the new image and recreate the container; Directus applies any pending database migrations on start:

docker compose pull
docker compose up -d
docker compose logs --tail=20 directus

Take a database dump before every update, so you can roll back if a migration fails.

Troubleshooting

  • Directus keeps restarting with a database connection error. Check docker compose logs database. If you changed DB_PASSWORD after the first start, PostgreSQL still has the old password stored in its volume. Change it back, or update the role with ALTER USER directus WITH PASSWORD '...' from docker compose exec database psql -U directus.
  • 502 Bad Gateway from Nginx. Directus is not listening yet or has crashed. Run docker compose ps and curl http://127.0.0.1:8055/server/ping.
  • Uploads fail with "413 Request Entity Too Large". Increase client_max_body_size in the Nginx server block and reload Nginx.
  • Links in emails or the Data Studio point to the wrong address. PUBLIC_URL must match the exact HTTPS URL users open. Fix it in .env and run docker compose up -d.

Conclusion

You now have Directus 11 running on Ubuntu 24.04 with PostgreSQL, Redis caching and HTTPS through Nginx, and you have exposed a collection through the REST API. From here you can connect a frontend framework such as Next.js or Astro to the API, configure S3-compatible storage for uploads with the STORAGE_* variables, and schedule the pg_dump command with cron so backups run automatically.