I self-hosted Directus — PUBLIC_URL sent asset links to the wrong host

I self-hosted Directus — PUBLIC_URL sent asset links to the wrong host

I wanted a Studio and APIs on top of Postgres I already understand. Directus came up on Ubuntu 24.04, then redirects and file URLs ignored my domain until PUBLIC_URL was the full HTTPS address Directus docs warn about.

· Updated · 2 min read #self-hosted #open-source #directus #cms #headless #nodejs #api #deployment #docker #vps

 Directus self-hosted headless CMS workspace

Caption: Studio for editors, REST/GraphQL for the frontend, Postgres I can dump. Not a mystery content blob.

Why I wanted this on my server

I like SQL I can inspect. I also like a browser Studio so I am not the only person who can edit a row. Directus sits on the database: schema in Postgres, APIs generated, files in a folder I mount.

Self-hosting means I pick the upgrade, the backup, and whether 8055 ever faces the internet (it does not).

What I actually installed

/opt/directus: Directus 12.0.2, postgis/postgis:13-master (as in Directus’ own example), Redis 6, bind mounts for uploads and extensions. Nginx to 127.0.0.1:8055. 2 vCPU / 2 GB for a small editorial box; more if files and flows pile up.

Where it broke

On a fresh Ubuntu 24.04 box this install is famous for PUBLIC_URL lying.

Studio loaded on HTTPS. Then redirects, asset URLs, and generated links pointed somewhere else — localhost, or http, or a leftover hostname. Directus uses PUBLIC_URL for links, files, redirects, and licensing. It has to be the URL people actually type:

PUBLIC_URL=https://directus.example.com

Then docker compose up -d so the container picks it up. Nginx already forwarded X-Forwarded-Proto; that was not enough without PUBLIC_URL.

First-boot exits are usually a malformed .env or DB_PASSWORD that does not match the PostGIS service. docker compose logs directus database before I delete volumes.

 Directus Docker Compose stack

Caption: Directus on localhost. Postgres, Redis, uploads, extensions on disk I back up.

Prerequisites

Docker + Compose v2, Nginx, Certbot, pin the Directus tag. SMTP later if I need invites. .env mode 600.

sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl jq gnupg openssl ufw nginx certbot python3-certbot-nginx tar

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

The working install

1. Install Docker Engine

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker

docker --version
docker compose version

2. Create the project directory

sudo mkdir -p /opt/directus/{uploads,extensions,data/database,data/redis,backups}
sudo chown -R "$USER":"$USER" /opt/directus
chmod 700 /opt/directus
chmod 700 /opt/directus/backups
cd /opt/directus

3. Generate secrets and create .env

cd /opt/directus

DIRECTUS_SECRET_VALUE="$(openssl rand -hex 32)"
DIRECTUS_DB_PASSWORD_VALUE="$(openssl rand -hex 24)"
DIRECTUS_ADMIN_PASSWORD_VALUE="$(openssl rand -base64 36 | tr -d '\n')"

cat > .env <<EOF
DIRECTUS_VERSION=12.0.2
DIRECTUS_DOMAIN=directus.example.com
PUBLIC_URL=https://directus.example.com
SECRET=${DIRECTUS_SECRET_VALUE}
DB_PASSWORD=${DIRECTUS_DB_PASSWORD_VALUE}
ADMIN_EMAIL=admin@directus.example.com
ADMIN_PASSWORD=${DIRECTUS_ADMIN_PASSWORD_VALUE}
LICENSE_KEY=
EOF

chmod 600 .env
printf 'Initial Directus admin password: %s\n' "${DIRECTUS_ADMIN_PASSWORD_VALUE}"

I change that admin password in Studio after first login and stop treating ADMIN_PASSWORD as a forever automation input.

4. Create compose.yaml

services:
  database:
    image: postgis/postgis:13-master
    restart: unless-stopped
    volumes:
      - ./data/database:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: "directus"
      POSTGRES_PASSWORD: "${DB_PASSWORD}"
      POSTGRES_DB: "directus"
    healthcheck:
      test: ["CMD", "pg_isready", "--host=localhost", "--username=directus"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_interval: 5s
      start_period: 30s

  cache:
    image: redis:6
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - ./data/redis:/data
    healthcheck:
      test: ["CMD-SHELL", "[ $$(redis-cli ping) = 'PONG' ]"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_interval: 5s
      start_period: 30s

  directus:
    image: directus/directus:${DIRECTUS_VERSION}
    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
    healthcheck:
      test: ["CMD-SHELL", "wget --spider -q http://localhost:8055/server/ping || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_interval: 5s
      start_period: 30s
    environment:
      SECRET: "${SECRET}"
      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}"
      PUBLIC_URL: "${PUBLIC_URL}"
      LICENSE_KEY: "${LICENSE_KEY}"
      WEBSOCKETS_ENABLED: "true"

Those healthchecks use start_interval. Docker Engine older than 25 will complain; I am on Engine 25+ here. Immich’s install docs are where I wrote that error out in full.

5. Start Directus

cd /opt/directus

docker compose config
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --since=10m database cache directus
curl -i http://127.0.0.1:8055/server/ping

/server/ping should return pong. /server/health is heavier; docs prefer ping for liveness.

6. Configure Nginx and HTTPS

Create /etc/nginx/sites-available/directus.example.com:

server {
    listen 80;
    listen [::]:80;
    server_name directus.example.com;

    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";
        proxy_read_timeout 300;
    }
}

Websocket headers stay; realtime needs them. 502: curl http://127.0.0.1:8055/server/ping and journalctl -u nginx.

sudo ln -s /etc/nginx/sites-available/directus.example.com /etc/nginx/sites-enabled/directus.example.com
sudo nginx -t
sudo systemctl reload nginx

sudo certbot --nginx -d directus.example.com
sudo certbot renew --dry-run

Admin: https://directus.example.com/admin.

Important Information

Roles before users. I create Editor and a read-only public role before I mint a static token. Tokens do not live in frontend bundles.

Uploads vanish after restart if ./uploads is not mounted or not owned by the user who runs Compose. I check the host folder, not only Studio.

Cache warnings: REDIS=redis://cache:6379 and a healthy cache service.

cd /opt/directus
docker compose up -d
docker compose logs --since=5m directus

First collection I actually used

articles: title, unique slug, status draft/published, body, publish_on. Public role reads published only.

 Directus data model planning

Caption: One small collection. Studio form for editors. API for the site. Drafts stay off the public role.

read -rsp "Directus API token: " DIRECTUS_TOKEN
printf '\n'

curl -sS \
  -H "Authorization: Bearer ${DIRECTUS_TOKEN}" \
  "https://directus.example.com/items/articles?limit=5" | jq

If admin works and public does not, it is permissions and filters, not Nginx.

Backup, expose, next step

cd /opt/directus
mkdir -p backups

BACKUP_DATE="$(date +%F-%H%M)"
docker compose exec -T database pg_dump -U directus directus > "backups/directus-${BACKUP_DATE}.sql"
tar -czf "backups/directus-files-${BACKUP_DATE}.tar.gz" uploads extensions .env compose.yaml
ls -lh backups

Restore: stop stack, restore SQL, unpack uploads/extensions, same .env SECRET, start. Practice on another machine.

 Directus operations checklist

Caption: Pinned version, dump, uploads, roles, ping. Upgrade in a window, not on Friday deploy autopilot.

What I have running now is Directus 12.0.2 on HTTPS, PostGIS + Redis, PUBLIC_URL correct, one articles collection. Next I connect one frontend, document roles, and restore a dump before this holds real copy.

Did you hit the same wall?

I got stuck on PUBLIC_URL — redirects and asset links ignored my HTTPS hostname until it matched the URL in the browser. Did you hit the same thing, or a different one? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

References

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.