What Directus asks of the host
| Item | Requirements page |
|---|---|
| Container, minimum | 1 x 0.25 vCPU / 512 MB |
| Container, recommended minimum | 2 x 1 vCPU / 2 GB |
| Databases | LTS releases of PostgreSQL, MySQL, MariaDB, SQLite, CockroachDB, OracleDB, MS SQL; not one database with several schemas |
| Redis | Optional; recommended for caching, required to scale horizontally |
| Files | A mounted local directory or a storage adapter (S3, GCS, Azure, Cloudinary, Supabase) |
Those figures cover Directus alone. PostgreSQL wants its own memory beside it, and the uploads folder grows with every asset, so disk is usually sized first. The image starts a single Directus instance under pm2: ecosystem.config.cjs sets PM2_INSTANCES to 1 unless overridden.
The machine to run it on
A dedicated server is one physical machine with a single tenant and root access, so PostgreSQL never competes with a neighbour for I/O. Classes from our range that fit:
- Moonshot cartridges when Directus has the box to itself. The m710x (Xeon E3-1585L v5, 4 cores at 3 GHz) carries NVMe and a console.
- Single-socket servers for Directus plus PostgreSQL, the layout below.
- Dual-socket servers when the frontends and workers that read the API share the host.
Racked single-, dual- and quad-socket servers each carry an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC), as do the m510 and m710x cartridges; other cartridge models get power control only. Ports are unmetered on our own AS19624 and terms are monthly with no contract. A ready (racked) machine is live about 30 minutes from payment approval; builds to order take 4 to 24 hours.
The compose stack against the docs example
The deploying guide's example runs PostGIS 17, Redis 7 and Directus with bind-mounted uploads and extensions. Only SECRET and a database are mandatory; PUBLIC_URL should be the final https address, since reset mails and OAuth redirects use it and a licence key binds to it. The configuration reference no longer documents KEY, so none is set. Our changes, in full:
| Docs example | This page |
|---|---|
| Secrets and admin inline in the compose file | Generated into .env (mode 600), read via env_file; Postgres takes DB_PASSWORD from it |
8055:8055 on every address | 127.0.0.1:8055:8055 |
Redis service with CACHE_* and REDIS | Dropped; caching stays off, its default |
| Health checks gating start-up | Dropped; start order plus restart: unless-stopped |
Tag REPLACE_WITH_VERSION | 12.5.1, from IMAGE in .env |
| No realtime or proxy settings | WEBSOCKETS_ENABLED and IP_TRUST_PROXY true |
Without the health check, bootstrap/index.ts still retries the database five times, five seconds apart, and the restart policy covers a slower first start. That is our reading of the source, not a documented guarantee.
Setting it up on Ubuntu 24.04
Install Docker Engine and the Compose plugin from Docker's apt repository, as the Directus Ubuntu tutorial does, then sudo -i, edit the first line and paste. The block halts below Engine 28, because 127.0.0.1-only publishing keeps other hosts out only from 28 (moby#45610), and it refuses to run when .env exists, since a rerun would replace SECRET and the database password under live data. Secrets go through echo, a bash builtin, so none becomes a process argument.
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
curl -s http://127.0.0.1:8055/server/ping returns pong when it is up. If the block stopped before docker compose up, nothing started: delete the partial .env and rerun. The chown hands both folders to the image's runtime user (the Dockerfile ends on USER node), which cannot write to root-owned mounts.
First admin, then the proxy
Left without ADMIN_EMAIL and ADMIN_PASSWORD, Directus gives its first visitor an onboarding screen that creates the admin. Here bootstrap made the account from .env (create-admin.ts skips it if either value is missing). Before anything is public:
- From your workstation, through your own login:
ssh -N -L 8055:127.0.0.1:8055 you@your-server. The first connection asks you to confirm the host key. - On the server,
grep ^ADMIN_PASSWORD /opt/directus/.env; sign in at http://localhost:8055 with it and yourADMIN_EMAIL. - Click your avatar at the bottom of the Studio's left-hand module bar, enter a new value in the Password field and save. The CLI route,
users passwd --password ..., exposes it inpsand shell history. - Drop the old value from the container:
sed -i '/^ADMIN_PASSWORD=/d' /opt/directus/.env && docker compose -f /opt/directus/docker-compose.yml up -d. Bootstrap reads it only on a fresh install.
docker-entrypoint.cjs starts cli.js bootstrap and pm2 with no credentials as arguments, so ps shows none, though whatever stays in .env is visible to root and the docker group through docker inspect. Next, publish the A record (plus AAAA where the server has IPv6). The block follows DNS, certificate, config: ufw with the OpenSSH profile, a lookup that halts if the name does not resolve, certbot, then the nginx site. Its flags answer certbot's three first-run prompts (contact email, Let's Encrypt terms, EFF mailing list); read the terms first, or drop --agree-tos to be asked.
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
Upgrade and Connection are taken from the Directus Ubuntu tutorial; they carry /websocket and GraphQL subscriptions on /graphql. The default heartbeat ping is every 30 seconds, within the 30 to 120 seconds the realtime docs recommend so that proxies do not close connections as idle. The TLS server, forwarded headers and upload size limit are our additions. IP_TRUST_PROXY defaults to false from 12.0.0; the stack sets it true, and nginx replaces X-Forwarded-For with the peer address so clients cannot spoof it.
Since 12.1.0 a browser WebSocket from another origin is refused unless CORS_ENABLED=true and CORS_ORIGIN names that origin (is-origin-allowed.ts; a bare * is rejected). The Studio on PUBLIC_URL needs neither. For a frontend served from a different address, add CORS_ENABLED=true and CORS_ORIGIN=https://www.example.com to .env and run docker compose -f /opt/directus/docker-compose.yml up -d, which recreates the container with them.
nginx is a host process, so ufw filters it on IPv4 and IPv6 alike, hence the 80/443 rule. A Docker mapping without an address would listen on 0.0.0.0 and [::]; its IPv4 traffic bypasses ufw, and so does IPv6 traffic once Docker's own IPv6 networking is enabled. Without that, docker-proxy holds the [::] listener, and ufw can block it like any process.
File storage: local folder or S3
STORAGE_LOCATIONS defaults to local with root ./uploads, mapped here to /opt/directus/uploads. For S3-compatible storage, set STORAGE_LOCATIONS=s3 with STORAGE_S3_DRIVER=s3, STORAGE_S3_KEY, STORAGE_S3_SECRET, STORAGE_S3_BUCKET, STORAGE_S3_REGION and, off AWS, STORAGE_S3_ENDPOINT (often with STORAGE_S3_FORCE_PATH_STYLE=true). New uploads land in the first location listed and each directus_files row records its own. Our inference: on an instance with existing files, list s3,local, and back the bucket up separately, because the archive below no longer covers new uploads.
Backups and a live restore
The docs leave backups to the operator. A complete set is the database dump plus uploads, extensions and the .env holding SECRET. This writes a mode-700 script that dumps under a temporary name (a failed pg_dump never leaves a finished-looking file), tests the archive, keeps 14 days and runs nightly from cron:
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
Pull /var/backups/directus from another machine with plain rsync -a, never --delete, and prune there with its own job. Rolling the live instance back to stamp T needs a root shell (sudo -i first); set C="docker compose -f /opt/directus/docker-compose.yml":
- Keep the present state: `$C exec -T database pg_dump -Fc -U directus directus > /root/before.dump && cat /root/before.dump | $C exec -T database pg_restore -l > /dev/null && tar -C /opt/directus -czf /root/before.tar.gz uploads extensions && tar -tzf /root/before.tar.gz > /dev/null && echo ok`. Stop unless it prints ok.
- Replace the database:
$C stop directus && $C exec -T database sh -c 'dropdb -U directus directus && createdb -U directus directus' && cat /var/backups/directus/db-T.dump | $C exec -T database pg_restore -d directus -U directus. - Replace the files:
rm -rf /opt/directus/uploads /opt/directus/extensions && tar -C /opt/directus -xzf /var/backups/directus/files-T.tar.gz uploads extensions && $C up -d, then check the Studio and some assets. - Delete
/root/before.*only once it works; if it does not, run steps 2 and 3 again with those files.
On a new machine, unpack the archive into /opt/directus before any container exists, because the Postgres image creates its user from .env on first start. Run $C up -d database, wait for $C exec database pg_isready -U directus to report connections accepted, restore the dump as in step 2, then $C up -d.
Upgrades and migrations
Releases come roughly monthly, and the docs want the breaking-changes list read before each one. Pin tags, not latest. To move: bash /opt/directus/backup.sh, then sed -i 's|^IMAGE=.*|IMAGE=directus/directus:NEW|' /opt/directus/.env and docker compose -f /opt/directus/docker-compose.yml up -d. The docs say migrations apply automatically; in the source, the entrypoint runs bootstrap, which migrates to latest before the server starts. By hand: docker compose -f /opt/directus/docker-compose.yml exec directus node /directus/cli.js database migrate:latest (the image has no npx). Rolling back means the pre-upgrade backup on the old tag.
From 11.x, three 12.0 changes matter here: licence enforcement (30 days' grace for instances using licensed features such as SSO or custom permission rules), IP_TRUST_PROXY off by default, and /server/health returning 403 without a token, so monitors should use /server/ping. Related builds: self-hosted Supabase and PostgreSQL on a dedicated server.
What the licence file says
Our general reading, not legal advice. From 12.0.0 the repository's license file is the Monospace Sustainable Core License 1.0 (MSCL-1.0-GPL). It allows use, copying, modification and redistribution for any purpose but a Competing Use: offering Directus to others in competition with the licensor's own paid Directus offerings. Internal use, non-commercial education and research, and professional services deploying or hosting it for a licensee are named as permitted. The licence-key code must not be moved, changed, disabled or circumvented to unlock or strip protected features, and redistributed copies carry the terms or a link. No revenue figure appears in it. Four years after each version's release, that version is also offered under GPL-3.0, whose terms make anyone distributing copies, modified or not, pass on the source.
The thresholds sit in the readme and licensing docs: without a key an instance runs on the Core tier, and the Open Innovation Grant offers free commercial use below 5 million US dollars of annual revenue and 50 employees. Releases 10.0.0 to 11.17.4 carried BSL 1.1 instead: production use while Total Finances (the largest of revenue, budget or funding) stayed within 5 million US dollars over 12 months, converting to GPL v3 three years after release.
Questions
Does a self-hosted Directus instance need a licence key?
Not for the Core tier: the licensing docs say an instance with no key runs on it, and the compose example in Create a Project leaves LICENSE_KEY empty. Features above Core, such as SSO and custom permission rules in Directus 12, need a key, which binds to PUBLIC_URL on first activation.
Is the KEY variable still required next to SECRET?
The current docs ask for SECRET and a database, and the configuration reference no longer lists KEY; guides that set both are older. Make SECRET a long random value: without it Directus picks a random one per start, so sessions do not survive a restart.
Why change the admin password if ADMIN_PASSWORD was random?
Because the value has sat in .env and in the container environment, where docker inspect shows it to root and the docker group. Changing it in the Studio and deleting the line leaves no working credential on disk. Bootstrap uses the variable only when installing a fresh database.