Workload · Running a headless CMS

Directus self hosted: your content platform on hardware nobody else uses

To run Directus self hosted, you start its official Docker image beside a PostgreSQL database on a machine you control, then point your website or app at the API it serves. This guide does it on one dedicated server, keeping the Studio out of public view until the admin account is yours, then adds HTTPS, restorable backups, updates and a plain summary of the licence.

The short version
  1. Directus plus PostgreSQL, started from one compose file
  2. Secrets made once, in a file only root can read
  3. Admin password changed over SSH before the site goes public
  4. Nightly copies of the database, your files and your settings
  5. Free Core tier with no key; the licence explained in plain words

How big a server does Directus need?

Not a huge one. Directus's requirements page puts its container's bare minimum at a quarter of a core and 512 MB, and recommends at least two copies of one core with 2 GB each. Plan disk around your media library.

This guide uses PostgreSQL, like the docs' production example. Redis is optional on one server.

The server that fits

With our dedicated servers you rent a whole physical computer: its processor, memory and disks work for your project alone, you log in as root, and nobody meters the port.

  • Small x86 cartridge servers suit Directus when nothing else shares the machine (the ARM cartridge cannot run Ubuntu 24.04, which this guide needs).
  • Everyday single-processor servers suit Directus with its database, the setup in this guide.
  • Two-processor servers suit Directus beside the website or app that reads from it.

A ready server reaches you within 30 minutes of the payment being approved; a custom build takes longer. If a firewall rule ever locks you out, every rack server and blade lets you open a console outside the operating system, with the screen and power button, and two cartridge models, m510 and m710x, do too; the remaining cartridge models can only be switched on and off. You can reinstall the system yourself in the control panel, and it is month to month, no contract.

Install it so only you can reach it

Start from Ubuntu 24.04 with Docker Engine and its Compose plugin, installed the way the Directus guide for Ubuntu shows, from Docker's own packages. Log in, run sudo -i, put your email and hostname on the first line, and paste. The block:

  1. Stops unless your Docker Engine version is at least 28: only from 28 on (moby#45610) does a port bound to 127.0.0.1 stay out of reach for other machines.
  2. Stops if /opt/directus/.env already exists, because a second run would swap the secret key and database password under data that depends on them.
  3. Makes an empty .env that only root can read (mode 600), and only then fills in a random SECRET, database password and admin password, your email, your address and the image tag (12.5.1).
  4. Gives the uploads and extensions folders to the image's own user, writes the compose file, and starts both services.
EMAIL=you@example.com; DOMAIN=cms.example.com; I=directus/directus:12.5.1
V=$(docker version --format '{{.Server.Version}}' 2>/dev/null)
if ! [ "${V%%.*}" -ge 28 ] 2>/dev/null; then echo "STOP: Engine 28+ needed, have '$V'"
elif [ -e /opt/directus/.env ]; then echo "STOP: .env exists, not replaced"
else
mkdir -p /opt/directus && cd /opt/directus && mkdir -p uploads extensions &&
install -m 600 /dev/null .env &&
{ echo "SECRET=$(openssl rand -hex 32)"; echo "DB_PASSWORD=$(openssl rand -hex 24)"
  echo "ADMIN_PASSWORD=$(openssl rand -hex 12)"; echo "ADMIN_EMAIL=$EMAIL"
  echo "PUBLIC_URL=https://$DOMAIN"; echo "IMAGE=$I"; } >> .env &&
chown "$(docker run --rm --entrypoint sh "$I" -c 'echo $(id -u):$(id -g)')" uploads extensions &&
cat > docker-compose.yml <<'EOF' &&
services:
  database:
    image: postgis/postgis:17-3.5
    restart: unless-stopped
    volumes: ["./data/database:/var/lib/postgresql/data"]
    environment: {POSTGRES_USER: directus, POSTGRES_DB: directus, POSTGRES_PASSWORD: "${DB_PASSWORD}"}
  directus:
    image: ${IMAGE}
    restart: unless-stopped
    depends_on: [database]
    ports: ["127.0.0.1:8055:8055"]
    volumes: ["./uploads:/directus/uploads", "./extensions:/directus/extensions"]
    env_file: .env
    environment: {DB_CLIENT: pg, DB_HOST: database, DB_PORT: "5432", DB_DATABASE: directus,
      DB_USER: directus, WEBSOCKETS_ENABLED: "true", IP_TRUST_PROXY: "true"}
EOF
docker compose -f /opt/directus/docker-compose.yml up -d
fi

Directus is ready when curl http://localhost:8055/server/ping prints pong. If it halted before its last command, nothing started: remove the unfinished .env and paste again.

Everything we changed from the deploying guide's example: passwords in .env rather than the compose file; the port on 127.0.0.1; no Redis service or cache settings (caching is off by default); no health checks, so Directus restarts until PostgreSQL answers; restart after a reboot; a pinned version; realtime and proxy trust switched on. The docs now ask only for a SECRET; the older KEY is gone from the configuration reference.

Make the admin account yours

Without admin details in its settings, Directus greets whoever opens it first with a setup screen that makes them the administrator. This install filled those in, so your account exists, but its password sits in a file. Swap it while the Studio is private:

  1. On your own computer, run ssh -L 8055:localhost:8055 -N you@server-ip with your own login (the first time, SSH asks you to accept the server's fingerprint).
  2. Show the starting password on the server with cd /opt/directus && grep ADMIN_PASSWORD .env, then log in on http://localhost:8055. Your avatar sits at the foot of the narrow bar on the Studio's left: click it, type the new password into the Password field and save.
  3. Remove the old line and recreate the container so it forgets it: cd /opt/directus && sed -i '/^ADMIN_PASSWORD/d' .env && docker compose up -d.

Why the Studio? The command-line route puts the password in the process list and shell history. The container's start file, docker-entrypoint.cjs, passes none that way, but root can still read what is left in .env through docker inspect. Directus reads ADMIN_PASSWORD only when installing an empty database.

Put it on your own domain with HTTPS

Three steps, in this order: point the hostname at your server (an A record for IPv4, AAAA as well when you use IPv6), get the certificate, then write the nginx site. The block does the last two after the firewall (IPv6 rules on, SSH via the OpenSSH profile, firewall enabled, web traffic allowed), and stops if your hostname does not resolve yet.

D=cms.example.com; EMAIL=you@example.com
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw && ufw allow OpenSSH && ufw --force enable &&
ufw allow 80,443/tcp && getent hosts "$D" && apt-get update && apt-get install -y nginx certbot &&
certbot certonly --webroot -w /var/www/html -d "$D" -m "$EMAIL" --agree-tos --no-eff-email \
  --deploy-hook "systemctl reload nginx" &&
cat > /etc/nginx/sites-available/directus <<EOF &&
server { listen 80; listen [::]:80; server_name $D;
  location /.well-known/acme-challenge/ { root /var/www/html; }
  location / { return 301 https://\$host\$request_uri; } }
server { listen 443 ssl; listen [::]:443 ssl; server_name $D;
  ssl_certificate /etc/letsencrypt/live/$D/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/$D/privkey.pem;
  client_max_body_size 100m;
  location / {
    proxy_pass http://127.0.0.1:8055;
    proxy_http_version 1.1;
    proxy_set_header Upgrade \$http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host \$host;
    proxy_set_header X-Forwarded-For \$remote_addr;
    proxy_set_header X-Forwarded-Proto https; } }
EOF
ln -sf /etc/nginx/sites-available/directus /etc/nginx/sites-enabled/ && nginx -t && systemctl reload nginx

Certbot's first run normally asks three things: an email for expiry warnings, whether you agree to Let's Encrypt's terms of service, and whether the EFF may have your email. The flags answer them; read the terms first, or remove --agree-tos to answer yourself.

The Upgrade and Connection lines, from the official Ubuntu tutorial, let live updates travel over WebSockets. The visitor's address goes in X-Forwarded-For because Directus 12 ignores proxy headers unless IP_TRUST_PROXY is set, as our compose file does. The rest of the nginx file is ours.

Will your website or app live on a different address from Directus and use live updates? From version 12.1.0, Directus turns away a browser's WebSocket from any other origin unless CORS_ENABLED=true is set and CORS_ORIGIN lists that exact origin (a plain * does not count). The Studio itself needs neither. Add both lines to .env, for example CORS_ORIGIN=https://www.example.com, then in /opt/directus run docker compose up -d so Directus restarts with them.

Because nginx runs straight on the server rather than in Docker, the firewall covers it on IPv4 and IPv6, hence the rule for 80 and 443. Had Directus been published with no address, it would listen on 0.0.0.0 and [::]; the firewall would miss its IPv4 traffic, and its IPv6 traffic as well whenever IPv6 networking is switched on inside Docker. If it is not, IPv6 passes through docker-proxy, a normal process that ufw filters.

Where your files live

Out of the box Directus keeps uploads on the server's disk, here in /opt/directus/uploads. To use Amazon S3 or a service that speaks its protocol, the files settings take STORAGE_LOCATIONS=s3 plus that location's details: STORAGE_S3_DRIVER=s3, key and secret, bucket, region, and an endpoint when it is not AWS. Add them to .env, then restart from /opt/directus with docker compose up -d.

New uploads go to the first location listed, and Directus records per file where each went. Switching an instance that already holds files? We suggest s3,local so the old ones stay reachable; that is our reading, not a documented step. Back the bucket up separately.

Copies you can bring back

A database dump alone falls short: your uploads, extensions and the .env with its secret key matter too. The block saves all four each night at 03:20 server time, keeps two weeks, and runs once now:

install -d -m 700 /var/backups/directus && install -m 700 /dev/null /opt/directus/backup.sh &&
cat > /opt/directus/backup.sh <<'EOF' &&
set -euo pipefail
umask 077
B=/var/backups/directus; T=$(date +%F-%H%M)
C="docker compose -f /opt/directus/docker-compose.yml"
$C exec -T database pg_dump -U directus -Fc directus > "$B/db-$T.part"
mv "$B/db-$T.part" "$B/db-$T.dump"
tar -C /opt/directus -czf "$B/files-$T.tar.gz" uploads extensions .env docker-compose.yml
tar -tzf "$B/files-$T.tar.gz" > /dev/null
find "$B" -type f -mtime +14 -delete
EOF
echo '20 3 * * * root umask 077; bash -o pipefail /opt/directus/backup.sh > /var/log/directus-backup.log 2>&1' > /etc/cron.d/directus-backup &&
bash /opt/directus/backup.sh && ls -l /var/backups/directus

A disk failure takes local copies with it, so fetch the folder from another machine with rsync -a (no --delete) and prune there with a separate job. You manage the server; we keep the hardware and the network running.

To roll back to a saved night, work as root: sudo -i, then cd /opt/directus, and put the date and time from the file names in place of STAMP:

  1. Save the present state and check both copies: `docker compose exec -T database pg_dump --username=directus --format=custom directus > /root/now.dump && cat /root/now.dump | docker compose exec -T database pg_restore --list > /dev/null && tar -czf /root/now-files.tgz uploads extensions && tar -tzf /root/now-files.tgz > /dev/null && echo ok`. Go on only if you see ok.
  2. Stop Directus, drop and recreate the database, then copy the saved dump in and load it: `docker compose stop directus && docker compose exec -T database dropdb --username=directus directus && docker compose exec -T database createdb --username=directus directus && docker compose cp /var/backups/directus/db-STAMP.dump database:/tmp/r.dump && docker compose exec database pg_restore --username=directus --dbname=directus /tmp/r.dump`.
  3. Swap the files in: rm -rf uploads extensions, then tar -xzf /var/backups/directus/files-STAMP.tar.gz uploads extensions, start everything with docker compose up -d, and look around the Studio.
  4. All good? Delete the two /root/now files. If not, go through steps 2 and 3 again using them.

Moving to a fresh server? Unpack the files archive into /opt/directus before starting anything, since PostgreSQL sets up its user and password from .env on its first start. Start just the database, wait until docker compose exec database pg_isready says it accepts connections, load the dump as step 2 does, then start the rest.

Updates without surprises

Directus releases about monthly; read the breaking-changes page before each update, and pin a version rather than latest. To update: bash /opt/directus/backup.sh, change the version on the IMAGE line of /opt/directus/.env, and from inside /opt/directus start it again with docker compose up -d. The database is migrated as the new version starts; if you ever need it by hand, the CLI's database migrate:latest runs inside the container through node /directus/cli.js, since the image ships without npx. To undo an update, restore the backup you took and set the old version back.

Coming from Directus 11? Version 12 enforces licences (30 days' grace if you already use features above the free tier, single sign-on for example), stops trusting proxy headers by default, and wants a token for /server/health, so point uptime checks at /server/ping. Running other self-hosted tools? See our guides to self-hosted Supabase and a PostgreSQL machine of your own.

What the licence lets you do

This is how we read it in general; treat it as information, not legal advice. Since version 12.0.0, the license file in the Directus repository calls itself the Monospace Sustainable Core License (MSCL-1.0-GPL), version 1.0. You may use, copy, change and share Directus for anything except a Competing Use, meaning offering it to other people as a rival to the paid Directus products of the company behind it. It lists internal use, non-commercial teaching and research, and setting it up or hosting it for a licensed client as allowed. Two conditions: leave the licence-key code alone (no unlocking or removing protected features), and include the terms, or a link to them, with any copy you hand on. Four years after a version comes out, it is also yours under GPL-3.0, which obliges anyone handing on copies, changed or not, to pass on the source code as well.

That file contains no money figure. The size limits come from the readme and licensing pages: an instance that has no key uses the free Core tier, while organisations with yearly revenue under 5 million US dollars and fewer than 50 employees can get free commercial use through the Open Innovation Grant. Directus 10 and 11 shipped under Business Source License 1.1, which allowed production use up to 5 million US dollars in total finances and became GPL v3 once three years had passed since each release.

Questions people ask

Is Directus free to run on my own server?
Often, yes. With no licence key it runs on the free Core tier, and organisations with less than 5 million US dollars in yearly revenue and fewer than 50 employees can apply for the Open Innovation Grant for free commercial use. Features beyond Core, single sign-on in version 12 for one, need a licence.
Can I move Directus to a bigger server later?
Yes. Take a backup, install Docker on the new machine, unpack the files archive into /opt/directus before starting anything, load the dump, then start it and switch the DNS record once it checks out.
Will live updates work behind nginx?
For the Studio, yes, once nginx passes the Upgrade and Connection headers and WEBSOCKETS_ENABLED is true, as here. A frontend on another address also needs CORS_ENABLED=true and its origin in CORS_ORIGIN, or Directus 12.1 and later refuse its socket.

Give your content a home of its own

Pick a server, install Ubuntu 24.04, and follow the steps above to have Directus running on your own domain.

Get a server for Directus

Read next