Directus is a different shape of headless CMS: instead of generating a schema from code, it wraps an existing SQL database and gives you a full data studio, REST and GraphQL APIs, roles, and webhooks on top. There's no application code to write and no build step in the usual sense — you deploy Directus itself, configured entirely through environment variables, and point it at a database.
That makes the deployment story unusual. The work isn't compiling anything; it's getting the environment variables, the initial bootstrap, and the file storage right. Here's the whole thing, start to finish.
The project: Directus as an npm dependency
You can run the official directus/directus Docker image directly, but for Git-push deploys I prefer a tiny repo that pins Directus as a dependency. You get a lockfile, a place for extensions, and an explicit start command — and the platform builds it like any Node app.
{
"name": "my-directus",
"private": true,
"dependencies": {
"directus": "^11"
},
"scripts": {
"start": "./start.sh"
}
}And start.sh (make it executable: chmod +x start.sh):
#!/bin/sh
set -e
# Map the platform-injected DATABASE_URL to what Directus expects
export DB_CLIENT=pg
export DB_CONNECTION_STRING="$DATABASE_URL"
# Idempotent: installs the system tables + admin user on first run,
# applies any pending Directus migrations on every run after that
npx directus bootstrap
exec npx directus startTwo decisions in that script are worth explaining.
The DATABASE_URL mapping. Directus doesn't read DATABASE_URL — it wants either discrete DB_HOST/DB_PORT/DB_USER/... variables or a single DB_CONNECTION_STRING. Managed platforms, PandaStack included, inject the standard DATABASE_URL. Rather than copying the value into a second variable by hand (which silently goes stale when credentials rotate), the wrapper maps it at boot. One line, and the app always uses whatever the platform injected.
bootstrap on every start. directus bootstrap is idempotent by design: on an empty database it creates the system tables and the first admin user; on a populated database it just runs any pending migrations and exits. Running it before start means a fresh deploy and an upgrade deploy follow the same code path, and version upgrades apply their internal migrations automatically.
The environment variables that matter
Directus is configured entirely by environment. This is the minimum viable production set:
SECRET=<long random string — openssl rand -hex 32>
ADMIN_EMAIL=you@example.com
ADMIN_PASSWORD=<strong password, used only on first bootstrap>
PUBLIC_URL=https://cms.yourdomain.comNotes from deploying this more than once:
SECRETis not optional. It signs tokens; if it changes between restarts, every session and static token breaks. Set it explicitly — don't let anything generate it per-boot. (Older Directus 10.x releases also required aKEYvariable; on current versionsSECRETalone is the one to care about.)ADMIN_EMAIL/ADMIN_PASSWORDare consumed by the *first* bootstrap only, to create your admin account. After first login, change the password in the app and you can remove these from the environment.PUBLIC_URLmatters more than it looks. Asset URLs, password-reset emails, and OAuth redirects are all built from it. If the Data Studio loads but images 404 or SSO loops, this is the first thing to check.- Port: Directus listens on 8055 by default and respects a
PORTvariable if the platform sets one.
Deploying on PandaStack
1. Provision the database. Create a managed PostgreSQL instance from the PandaStack dashboard — 14.x and 16.x are available, with scheduled daily backups (retained 7/15/30 days depending on plan) plus manual backups when you want a safety snapshot before something risky.
2. Create the container app from your repo. PandaStack auto-detects the Node project and uses your start script; there's no build step for Directus beyond npm install, so builds are quick. If you'd rather be explicit, a Dockerfile works too — this is all it takes:
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY start.sh ./
COPY extensions ./extensions
CMD ["./start.sh"]Either way, the image is built with rootless BuildKit in an ephemeral Kubernetes job pod and deployed from there — no host Docker socket involved.
3. Attach the database to the app. This injects DATABASE_URL into the app's environment automatically. Combined with the start.sh mapping, the database connection requires zero manual configuration — no credentials copied, nothing to rotate by hand.
4. Set the environment variables from the section above in the app's settings.
5. Push.
git push origin mainBuild logs stream live in the dashboard. On the first boot you'll see bootstrap create the system tables, then Directus starts and the Data Studio is live at your app URL. Log in with the admin credentials, change the password, and start modeling collections.
GET /server/health is Directus's built-in health endpoint if you want to point monitoring at something more meaningful than a TCP check.
File storage: the gotcha that bites everyone
By default Directus stores uploaded files on the local filesystem. In a container, that filesystem is ephemeral — a redeploy or restart wipes every upload. This is the single most common way people lose data with containerized Directus, and it doesn't announce itself until the first redeploy after someone uploads real content.
Point storage at any S3-compatible bucket instead:
STORAGE_LOCATIONS=s3
STORAGE_S3_DRIVER=s3
STORAGE_S3_KEY=<access key>
STORAGE_S3_SECRET=<secret key>
STORAGE_S3_BUCKET=<bucket>
STORAGE_S3_REGION=<region>
STORAGE_S3_ENDPOINT=<endpoint, for non-AWS providers>Any S3-compatible provider works. Do this before editors touch the instance, not after.
Schema changes across environments
Because Directus schema lives in the database rather than in code, promoting changes from staging to production needs its own mechanism. Directus ships one: schema snapshots.
# On staging: export the current schema
npx directus schema snapshot ./snapshot.yaml
# On production: preview, then apply
npx directus schema apply --dry-run ./snapshot.yaml
npx directus schema apply ./snapshot.yamlCommit the snapshot to your repo and applying it becomes a reviewable, repeatable step instead of clicking the same changes into two dashboards and hoping they match. The --dry-run flag shows the SQL-level diff before anything runs — use it every time.
Note the distinction: bootstrap handles *Directus's internal* migrations (system tables, version upgrades) automatically. Snapshots handle *your* collections and fields. You need both, and they don't overlap.
Sizing and free-tier behavior
Directus idles light but is a real Node process — the free tier (0.25 CPU, 512 MB RAM) runs it fine for a hobby project or a content backend for a static site. Two free-tier behaviors to know about:
- Scale-to-zero. Idle free-tier apps scale to zero and cold-start on the next request. For a CMS whose content is consumed by a static site at build time, this is close to irrelevant. If editors live in the Data Studio all day, or your frontend queries the Directus API on every page view, a paid tier keeps the instance warm.
- Database sizing. Free-tier databases get a small storage volume and a 50-connection limit — comfortable for one Directus instance in dev or hobby use. Production content workloads belong on a bigger tier.
Add a custom domain in the dashboard (SSL is automatic), set PUBLIC_URL to match, and you're done — a managed Postgres, a bootstrapped Directus, durable file storage, and schema changes that travel through Git.
The whole setup above is a five-file repo and one push; [pandastack.io](https://pandastack.io) is the quickest way to see it running.