directus
Directus work in this environment usually falls into one of three buckets:
- Frontend consuming Directus at build time: Astro/Next/static site pulls content from Directus.
- Self-hosted Directus instance: Docker, domains, env vars, storage, backups, health checks.
- Directus internals: permissions, multitenancy, flows, hooks, endpoints, schema snapshots.
Start by classifying which bucket you are in. Do not jump straight into app code or env-var edits.
Triggers
Load this skill when ANY of:
- Files:
docker-compose*.y*mlreferencingdirectus/directus,snapshot.yaml,extensions/,directus.config.{js,ts},infra/directus/,.env.examplewithDIRECTUS_*orPUBLIC_URL,package.jsonwith@directus/sdkor@directus/extensions-sdk - User mentions: Directus, collection, item, field, flow, hook, endpoint, operation, policy, role, filter rule, permission, snapshot,
directus_users,directus_files,PUBLIC_URL,CORS_ORIGIN,server/health
First 5 Minutes
Read these in order before changing anything:
- Runtime entrypoint:
docker-compose.yml, k8s manifests, orinfra/directus/ - Env contract:
.env.example, deployment docs, secrets template - Schema source of truth:
snapshot.yaml,snapshots/, or migrations - Frontend consumer:
src/lib/directus.*,src/env.d.ts, build/tests that call Directus - Extensions and ops scripts:
extensions/,healthcheck.sh, backup/restore scripts
Then decide the working mode explicitly:
- Live CMS mode: Directus is reachable and you will verify against it.
- Offline cached mode: build/test against previously cached Directus responses.
- Offline seeded mode: build/test against committed seed fixtures or migration output because Directus is down.
If you do not decide the mode up front, agents repeatedly waste time chasing the wrong failure.
Core Mental Model
Directus mirrors your database. There are two separate surfaces:
- Database schema: tables, columns, FKs, indexes
- Directus metadata:
directus_collections,directus_fields,directus_relations,directus_permissions, interfaces, presets, validations
Raw SQL migrations only cover the first. Schema snapshots cover both. This is the root cause of many "works locally, staging is wrong" bugs.
System collections you will touch most often:
directus_usersdirectus_rolesdirectus_policiesdirectus_permissionsdirectus_filesdirectus_foldersdirectus_flowsdirectus_operations
Query them via system endpoints like /users or /files, not /items/directus_users.
Happy Path A: Frontend Consuming Directus
This is the most common failure cluster from session history. Treat it as a first-class integration, not "just another fetch."
1. Lock the env contract first
For build-time frontends:
DIRECTUS_URLis server-side only- Browser-visible vars use
PUBLIC_* - Commit
.env.example - Add
src/env.d.tssoimport.meta.env.DIRECTUS_URLis typed
Guardrail:
- Do not rename
DIRECTUS_URLtoPUBLIC_DIRECTUS_URLunless the browser truly needs to hit the CMS directly. - Do not debug browser CORS for an Astro build-time fetch. CORS matters for browser/runtime/admin traffic, not server-side build fetches.
2. Use an explicit offline strategy
For Directus-backed static builds, the official fallback order is:
- Live Directus
- Cached API payloads
- Committed migration-seeded fixtures
nullfor single-item lookups that genuinely have no fallback
Make this behavior intentional in code:
- List/archive helpers should usually return cached or seeded data instead of throwing.
- Single-item helpers can return
nullwhen there is no cached/seeded equivalent. - Log the tier that was used so build/test failures are explainable.
This is not a hack. In repeated sessions, agents only got deterministic builds after promoting seeded offline data to an official path.
3. Do not run ambiguous builds
Allowed modes:
# Live CMS mode
DIRECTUS_URL=https://cms.example.com pnpm build
# Offline cached/seeded mode
DIRECTUS_URL="" pnpm build
Forbidden mode:
- Running a build/review with Directus down, an empty cache, and no seeded fixtures, then treating the output as authoritative
Guardrail:
- Never regenerate signoff snapshots or release artifacts from an empty-cache build unless the offline seeded path is intentional and documented.
4. Isolate cache in tests
Repeated failure pattern: one test or build job clears .cache while another test is reading from it.
Official rule:
- Tests that exercise Directus fallback must use a per-process temp cache dir via
CACHE_DIR - Build-verification tests must not share mutable cache state with unit tests
Example pattern:
vi.stubEnv('CACHE_DIR', join(tmpdir(), `directus-cache-${process.pid}`));
If tests and build verification both touch .cache, assume you need isolation.
5. Prefer typed SDK usage
import {
createDirectus,
rest,
readItems,
readItem,
} from '@directus/sdk';
interface Schema {
blog_articles: BlogArticle[];
case_studies: CaseStudy[];
}
const directus = createDirectus<Schema>(process.env.DIRECTUS_URL!).with(rest());
const posts = await directus.request(
readItems('blog_articles', {
filter: { status: { _eq: 'published' } },
sort: ['-date_published'],
fields: ['id', 'title', 'slug'],
}),
);
Guardrails:
fields: ['*']returns scalars only- For relations, use explicit nested fields or
*.* - Avoid
*.*.*in production - Wrap Directus fetches with timeout/error handling so "Directus down during build" does not become a cryptic crash
6. Make rebuild hooks explicit
For static sites, create a Directus Flow that triggers your deploy hook when content changes:
- Trigger: create/update/delete on relevant collections
- Action: webhook to Pages/Hosting deploy hook
Do not rely on "someone remembers to redeploy after editing content."
Happy Path B: Self-Hosted Directus
Default stack signature here:
directus/directus:11.x
postgres:16
Node.js 22 if building extensions
pnpm >=10 <11 for extension work
Confirm these env vars before touching anything operational:
SECRETKEYPUBLIC_URLDB_CLIENT- DB connection vars
STORAGE_LOCATIONS
Missing or rotating SECRET/KEY causes auth instability and session churn.
Health checks are mandatory
Minimum verification surface:
curl -fsS "$DIRECTUS_URL/server/health"
curl -fsS "$DIRECTUS_URL/server/info"
curl -fsS "$DIRECTUS_URL/items/<public_collection>?limit=1&fields=id"
If you write a shell healthcheck under set -e, avoid ((FAILURES++)) in failure paths. Use:
FAILURES=$((FAILURES + 1))
That exact bug caused false script exits in real Directus ops sessions.
Custom domain and admin triage
When the Directus admin shell loads but hydrates badly, or browser requests fail with AxiosError: Network Error, check this first:
- Compare the browser address bar host to the failing request URL
- If they differ, suspect
PUBLIC_URL - Inspect
CORS_ORIGIN
Rules:
PUBLIC_URLmust match the hostname users actually visitCORS_ORIGINmay need a comma-separated allowlist- Hard-refresh or use a private window after env changes
Typical fix:
PUBLIC_URL=https://cms.example.com
CORS_ENABLED=true
CORS_ORIGIN=https://app.example.com,https://cms.example.com
Rollback tactic when env changes broke the admin:
- Remove
PUBLIC_URLentirely and let Directus derive it from the request host - Remove or simplify
CORS_ORIGIN - Restart and retest
This rollback path repeatedly unblocked broken self-hosted admin sessions and should be considered official, not improvised.
Storage and backups
- Local
./uploadsis fine for dev, not for multi-instance prod - Use S3/R2/GCS for anything past a single container
- Back up the database and object storage
Guardrail:
- A non-empty
.sql.gzfile is not proof of a valid backup - Validate compressed dumps with
gunzip -t - Inspect the header for a Postgres dump signature
- Resolve the target postgres container explicitly; do not rely on loose
name=postgressubstring matches in shared Docker hosts
Happy Path C: Schema Promotion
Promote schema with snapshots, not raw SQL alone:
# source env
docker compose exec directus npx directus schema snapshot ./snapshot.yaml --yes
# target env
docker compose exec directus npx directus schema apply ./snapshot.yaml --yes
After schema apply, clear schema cache:
docker compose restart directus, orPOST /utils/cache/clearwith an admin token
This cache invalidation step was repeatedly skipped in real sessions. Treat it as mandatory.
Fresh environment order:
directus bootstrap- custom DB migrations
directus schema apply ./snapshot.yaml- restart / clear cache
Never modify directus_* system tables from raw SQL migrations.
Multitenancy: Row-Level Pattern
Default tenancy model in this environment is single-instance row-level tenancy.
Required shape:
tenants(id, name, slug, ...)
posts(id, tenant_id, ...)
projects(id, tenant_id, ...)
ALTER TABLE directus_users ADD COLUMN tenant_id uuid REFERENCES tenants(id);
Permission filter on every tenant-scoped collection:
{
"tenant_id": { "_eq": "$CURRENT_USER.tenant_id" }
}
Create preset:
{ "tenant_id": "$CURRENT_USER.tenant_id" }
Guardrails:
- Exclude
tenant_idfrom user-updatable fields - Never give tenant admins
admin_access: true - Never leave a tenant-scoped collection without
tenant_id - Audit the Public role on every project
Dynamic variables you will actually use:
$CURRENT_USER$CURRENT_USER.tenant_id$CURRENT_ROLE$CURRENT_ROLES$CURRENT_POLICIES$NOW
Extensions, Hooks, Endpoints, Flows
Scaffold extensions with:
npx create-directus-extension@latest
Use the right surface:
- Flow: visual automation, cross-system glue, webhooks, schedules
- Hook: block or mutate writes in-process
- Endpoint: bespoke REST shape or server-side RPC
- Operation: reusable Flow step
The accountability rule
This is the main Directus extension footgun:
accountability: nullruns as root- It bypasses normal permissions and tenant filters
Rules:
- Endpoints should pass
req.accountability - User-triggered hooks/operations must preserve real accountability when possible
- If a root-level operation is unavoidable, stamp
tenant_idexplicitly and document why
Example endpoint pattern:
import { defineEndpoint } from '@directus/extensions-sdk';
export default defineEndpoint((router, { services, getSchema }) => {
router.get('/tenant-stats', async (req, res) => {
if (!req.accountability?.user) return res.status(401).end();
const schema = await getSchema();
const items = new services.ItemsService('posts', {
schema,
accountability: req.accountability,
});
const result = await items.readByQuery({ aggregate: { count: ['id'] } });
res.json(result);
});
});
Verification Checklist
Before you call the work done, verify the path you actually touched:
- Frontend:
- build passes in the intended live or offline mode
- fallback tier is explicit in logs
- tests do not share mutable Directus cache state
- no browser-only env vars were used for server-side fetches
- Self-hosted CMS:
/server/healthand/server/infopass- one representative collection query passes
PUBLIC_URLand real hostname match- hard refresh/private window after env changes
- Schema changes:
- snapshot committed
- apply step documented
- restart or
/utils/cache/clearperformed
- Multitenancy/extensions:
- no
accountability: nullleaks - tenant filter/preset present
- Public role audited
- no
Red Flags: Stop And Ask
- Build output or signoff was generated from Directus-down + empty-cache mode
- Tests share one mutable
.cachebetween build verification and Directus unit tests PUBLIC_URLdoes not match the admin hostname in the browserCORS_ORIGINonly includes the public app but the admin is cross-origin- New schema applied without restart/cache clear
- Raw SQL touches
directus_*tables - Root-level extension/service call (
accountability: null) is being used for user-triggered work - Tenant-scoped collection has no
tenant_id - Public role can read tenant or draft data
- Backup scripts only check "file exists" and not dump integrity
What To Read First In An Unfamiliar Directus Repo
docker-compose.ymlor infra manifests.env.examplesnapshot.yamlor migration foldersrc/lib/directus.*or equivalent CMS clienttests/*directus*and build verification testsextensions/healthcheck.sh, backup scripts, restore docs