Skip to main content

docker-local-dev

Run Directus 12 locally with Docker Compose — PostgreSQL, Redis, upload and extension volumes, secrets from .env, /server/ping health check, pinned image tag, first admin, extensions on hardened images, upgrades. This skill should be used when the user asks to "run Directus locally", "Directus Docker Compose", "Directus local dev stack", "start a local Directus instance", "docker-compose.yml for Directus", or needs a throwaway Directus to develop a frontend, flows or schema against.

Source facts

Repository
Agents-Store/opencode-plugins
Last source activity
October 3, 2026 at 23:16
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
docker-local-dev
description
Run Directus 12 locally with Docker Compose — PostgreSQL, Redis, upload and extension volumes, secrets from .env, /server/ping health check, pinned image tag, first admin, extensions on hardened images, upgrades. This skill should be used when the user asks to "run Directus locally", "Directus Docker Compose", "Directus local dev stack", "start a local Directus instance", "docker-compose.yml for Directus", or needs a throwaway Directus to develop a frontend, flows or schema against.
# Directus Local Development with Docker Compose Run Directus 12 on your machine with PostgreSQL and Redis, driven by environment variables. Everything below is generic: the compose file contains no values, only `${VAR}` references. Real values live in a gitignored `.env`. The result is a development stack, not a production deployment. For production, read the Directus self-hosting docs (secrets management, backups, reverse proxy, `IP_TRUST_PROXY`, storage adapter). ## Prerequisites - Docker Engine 25 or later with Compose 2.20.2 or later (`docker compose version`). The health checks below use `start_interval`, which older versions reject or ignore. On an older setup delete the three `start_interval` lines and the stack still works, only slower to report healthy - A free host port for Directus (8055 is the usual default, set `LOCAL_DIRECTUS_PORT` when it is taken) ## Files ``` my-project/ ├── docker-compose.yml ├── .env # real values, gitignored ├── .env.example # placeholders, committed ├── uploads/ # Directus file storage (bind mount) ├── extensions/ # custom extensions (bind mount) └── data/database/ # PostgreSQL data (bind mount) ``` ### `.env.example` Commit this file with placeholders. Copy it to `.env` and fill it in. ```bash # Image: pin an exact version, do not use "latest". Tested up to 12.4.1. LOCAL_DIRECTUS_VERSION=12.4.1 LOCAL_DIRECTUS_PORT=8055 # Generate once: openssl rand -hex 32 LOCAL_DIRECTUS_SECRET=<random-64-hex-characters> # First admin account, created on first start LOCAL_DIRECTUS_ADMIN_EMAIL=admin@example.com LOCAL_DIRECTUS_ADMIN_PASSWORD=<local-admin-password> # Optional static token for that admin (for scripts and MCP in local dev only) LOCAL_DIRECTUS_ADMIN_TOKEN=<local-static-token> LOCAL_DB_PASSWORD=<local-database-password> # Origin of your frontend dev server, for CORS (also enforced for WebSockets since 12.1) LOCAL_FRONTEND_ORIGIN=http://localhost:3000 # Optional: leave empty for the Core tier LOCAL_DIRECTUS_LICENSE_KEY= ``` Add `.env`, `uploads/`, `data/` to `.gitignore`. ### `docker-compose.yml` ```yaml services: database: image: postgis/postgis:17-3.5 environment: POSTGRES_USER: directus POSTGRES_PASSWORD: ${LOCAL_DB_PASSWORD:?set LOCAL_DB_PASSWORD in .env} POSTGRES_DB: directus volumes: - ./data/database:/var/lib/postgresql/data healthcheck: test: ["CMD", "pg_isready", "--host=localhost", "--username=directus"] interval: 10s timeout: 5s retries: 5 start_interval: 5s start_period: 30s cache: image: redis:7 healthcheck: test: ["CMD-SHELL", "[ $$(redis-cli ping) = 'PONG' ]"] interval: 10s timeout: 5s retries: 5 start_interval: 5s start_period: 30s directus: image: directus/directus:${LOCAL_DIRECTUS_VERSION:-12.4.1} ports: - "127.0.0.1:${LOCAL_DIRECTUS_PORT:-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://127.0.0.1:8055/server/ping || exit 1"] interval: 10s timeout: 5s retries: 5 start_interval: 5s start_period: 30s environment: SECRET: ${LOCAL_DIRECTUS_SECRET:?set LOCAL_DIRECTUS_SECRET in .env} DB_CLIENT: pg DB_HOST: database DB_PORT: "5432" DB_DATABASE: directus DB_USER: directus DB_PASSWORD: ${LOCAL_DB_PASSWORD} CACHE_ENABLED: "true" CACHE_AUTO_PURGE: "true" CACHE_STORE: redis REDIS: redis://cache:6379 ADMIN_EMAIL: ${LOCAL_DIRECTUS_ADMIN_EMAIL:?set LOCAL_DIRECTUS_ADMIN_EMAIL in .env} ADMIN_PASSWORD: ${LOCAL_DIRECTUS_ADMIN_PASSWORD:?set LOCAL_DIRECTUS_ADMIN_PASSWORD in .env} ADMIN_TOKEN: ${LOCAL_DIRECTUS_ADMIN_TOKEN:-} PUBLIC_URL: http://localhost:${LOCAL_DIRECTUS_PORT:-8055} WEBSOCKETS_ENABLED: "true" CORS_ENABLED: "true" CORS_ORIGIN: ${LOCAL_FRONTEND_ORIGIN:-http://localhost:3000} # Lets the Studio embed your frontend in an iframe (Live Preview) CONTENT_SECURITY_POLICY_DIRECTIVES__FRAME_SRC: ${LOCAL_FRONTEND_ORIGIN:-http://localhost:3000} LICENSE_KEY: ${LOCAL_DIRECTUS_LICENSE_KEY:-} EXTENSIONS_AUTO_RELOAD: "true" ``` Notes on the file: - **`LOCAL_` prefix.** Compose gives variables exported in your shell precedence over `.env`, silently. A shell that already exports `DIRECTUS_*` for your app (a `DIRECTUS_ADMIN_TOKEN`, say) would override the stack's value with no warning, and a real credential could end up in a throwaway container. The prefix keeps the stack's variables apart from the app's `DIRECTUS_URL` and `DIRECTUS_TOKEN`. - **Loopback port.** `127.0.0.1:` in the port mapping keeps the stack off your network: the local admin password and static token are throwaway values, and a plain `8055:8055` would publish them on every interface of the machine. Remove the prefix only when another device has to reach the instance. - **Pinned tag.** `directus/directus:${LOCAL_DIRECTUS_VERSION}` keeps restarts from silently upgrading. Directus 12 enforces licensing and has breaking changes between minor versions, so upgrades must be deliberate. - **`:?` guards.** Compose stops with the message if a required variable is missing, instead of starting Directus with an empty `SECRET`. - **Health check** calls `127.0.0.1`, not `localhost`: the image's `wget` tries the IPv6 address for `localhost` first and Directus listens on IPv4 only, so a `localhost` probe reports the container as unhealthy while it works fine. - **`CONTENT_SECURITY_POLICY_DIRECTIVES__FRAME_SRC`** is what lets Live Preview show your frontend inside the Studio. Without it the browser refuses the iframe. Your frontend must allow the Studio origin as well (`Content-Security-Policy: frame-ancestors 'self' <studio-origin>`), see the Directus Live Preview guide. - **`ADMIN_*`** create the first admin on an empty database, so the onboarding screen is skipped. They are ignored once the database already has users. - **`PUBLIC_URL`** must match the address you open in the browser. Licensing binds to it on first use and OAuth redirects use it. - **PostGIS image** is the one the Directus docs use, so geometry field types work. A plain `postgres` image also works if you do not need them. - **`LICENSE_KEY`** stays empty for local development (Core tier). A license key binds to the project and `PUBLIC_URL` on first use. Deleting a stack that holds a key without deactivating the license first (Settings → License) strands the activation against the license's activation limit, so never test a production key in a throwaway stack. - **Redis** is optional for one instance. It is included to match production behavior (cache, rate limits). ## Start and Verify Compose reads `.env` by itself. Your shell does not, so the `curl` commands below need the values loaded first: `set -a; . ./.env; set +a`. ```bash mkdir -p uploads extensions data/database sudo chown 1000:1000 uploads extensions # Linux: the container user "node" is uid 1000 cp .env.example .env # then edit .env docker compose up -d docker compose ps # wait until directus is "healthy" # Liveness: public, answers "pong" once the HTTP server runs curl -sS "http://localhost:${LOCAL_DIRECTUS_PORT:-8055}/server/ping" ``` `/server/ping` is the right probe. `/server/health` checks the database, Redis, storage and email, but Directus 12 answers `403` to it without a token. Dependency check with the admin token (`200` with `"status": "ok"` when the database, Redis, storage and email checks pass): ```bash curl -sS "http://localhost:${LOCAL_DIRECTUS_PORT:-8055}/server/health" \ -H "Authorization: Bearer ${LOCAL_DIRECTUS_ADMIN_TOKEN}" ``` Then open the Studio in the browser at the `PUBLIC_URL` and sign in with the admin account. The Directus container runs as the unprivileged `node` user (uid 1000). On Linux the bind-mounted `uploads/` and `extensions/` directories must be writable by that uid, which the `chown` above does. When they are owned by another user, uploads fail with `EACCES`, and `/server/health` returns `503` with a `storage:local` error (health results are cached for `HEALTHCHECK_CACHE_TTL`, five minutes by default, so a fix can take that long to show). Docker Desktop on macOS and Windows usually needs no change. ## Connect Your Tools ```bash # App .env (a .env file does not expand variables: write the literal address and token) DIRECTUS_URL=http://localhost:8055 # the port you put in LOCAL_DIRECTUS_PORT DIRECTUS_TOKEN=<value of LOCAL_DIRECTUS_ADMIN_TOKEN> # local dev only ``` - **SDK.** See the `sdk-patterns` skill: static token on the server, `authentication('json')` for a login in Node, `authentication('session', { credentials: 'include' })` in the browser. - **MCP.** Enable it once in the Studio under Settings → AI → Model Context Protocol, then register `${DIRECTUS_URL}/mcp` with your client (see `mcp-tools`). For a local instance a static token is the simplest start. Create a dedicated user with its own policy for AI work rather than reusing the admin account. - **Frontend dev server** on another origin: set `LOCAL_FRONTEND_ORIGIN` to it. The origin must be listed in `CORS_ORIGIN` for both REST and WebSockets. ## Daily Commands ```bash docker compose logs -f directus # follow logs docker compose restart directus # after changing .env or extensions docker compose down # stop, keep data ``` To start over, stop the stack and delete the `data/` and `uploads/` directories. The next `up` bootstraps a fresh database and recreates the admin from `.env`. ## Moving a Schema Between Instances Snapshot the local schema, diff it against another instance and apply (admin token, see `api-reference` for the pipeline): ```bash curl -s -H "Authorization: Bearer ${LOCAL_DIRECTUS_ADMIN_TOKEN}" \ "http://localhost:${LOCAL_DIRECTUS_PORT:-8055}/schema/snapshot" > schema-snapshot.json ``` Snapshots are capped by `IMPORT_MAX_FILE_SIZE` (default `50mb`) when uploaded to `/schema/diff` or `/schema/apply`. ## Extensions Mount extensions into `./extensions` and keep `EXTENSIONS_AUTO_RELOAD: "true"` for local development. The published Directus 12 images are hardened: `npm` and `npx` are removed from the runtime, and the `-dhi` variant (for example `directus/directus:12.4.1-dhi`) is distroless with no shell at all. So you cannot install extensions or run `npx directus ...` inside the container. - **Build outside, copy in.** Install and build the extension on your machine or in a build stage, then mount the finished directory (it needs a `package.json` and a `dist/` folder) into `extensions/`. - **Bake into an image** with a multi-stage Dockerfile and copy with `--chown=node:node`: ```dockerfile FROM node:22-alpine AS build WORKDIR /extension-build RUN corepack enable && pnpm init && pnpm add <extension-package> FROM directus/directus:12.4.1 COPY --from=build --chown=node:node /extension-build/node_modules/<extension-package> /directus/extensions/<extension-name> ``` Keep the Alpine version of the build stage in line with the Directus image when an extension has native dependencies. The `-dhi` image has no `wget`, so the compose health check above does not work with it: probe `/server/ping` from outside the container instead. - **Directus CLI** is still there, call it with `node`: ```bash docker compose exec directus node /directus/cli.js database migrate:latest ``` ## Upgrading 1. Read the breaking changes for every version you skip (Directus docs, Releases, Breaking changes). Directus 12 notes: `/server/health` needs a token, `IP_TRUST_PROXY` defaults to `false`, the Studio shows published items of versioned collections read-only, update and delete flow operations without a target return `null`. 12.4.0 has a bug reading `directus_folders` as a non-admin, so go to 12.4.1 or later. 2. Back up first: `docker compose exec database pg_dump --username=directus directus > backup.sql`, plus the `uploads/` directory. 3. Change `LOCAL_DIRECTUS_VERSION` in `.env`, then `docker compose pull directus && docker compose up -d`. Migrations run automatically on start. 4. Check `docker compose logs directus` and `/server/ping`. To go back to an older version, restore the backup taken before the upgrade. Changing the image tag back alone leaves a database that was already migrated by the newer version. ## Troubleshooting | Symptom | Cause and fix | |---------|---------------| | `required variable ... is missing a value` | A `${VAR:?...}` guard fired. Set the variable in `.env` | | A value in the running container is not the one in `.env` | A variable with the same name is exported in your shell and wins over `.env`. Unset it, or run `docker compose` from a clean environment with `env -i PATH="$PATH" HOME="$HOME" docker compose up -d` | | Port already in use | Another process owns the host port. Set `LOCAL_DIRECTUS_PORT` to a free one and update `PUBLIC_URL` consumers | | Directus restarts in a loop, logs show database connection errors | Wrong `LOCAL_DB_PASSWORD`. The database directory keeps the first password: delete `data/database` to re-initialize, or change the password inside PostgreSQL | | Login works, but the browser app gets CORS errors | `LOCAL_FRONTEND_ORIGIN` does not match the app origin exactly (scheme, host and port) | | `503` from `/server/health` with a `storage:local` error, uploads fail with `EACCES` | `uploads/` is not writable for uid 1000, see the `chown` in Start and Verify | | `403` from `/server/health` | Expected without a token in Directus 12. Use `/server/ping` | | Cannot sign in with SSO | Directus 12 needs a licensed tier for SSO. Use an email/password user locally | | Assets return `403` for anonymous requests | Files are private by default. Grant the Public policy read on `directus_files`, or serve them through a server route that adds the token. Never put `?access_token=` in a URL a browser sees (see `troubleshoot`) |
View on GitHub