How to Self-Host Directus — Open Source CMS 2026
How to Self-Host Directus — Open Source CMS 2026
TL;DR
Directus is a "data engine" — it wraps any SQL database (PostgreSQL, MySQL, SQLite, MSSQL, Oracle) and automatically generates a REST API, GraphQL API, and visual data management interface around your existing tables. Unlike Strapi or Contentful, Directus doesn't force a proprietary data model — it works with your schema as-is, making it a zero-migration headless CMS layer on top of your existing database. Self-hosting is free under the BSL 1.1 license (commercial use requires a license for certain features, but self-hosting for your own project is unrestricted).
Key Takeaways
- Directus: 28K+ GitHub stars, TypeScript, works with any existing SQL database without migration
- Dual purpose: headless CMS for content teams + admin data panel for developers — replaces both Contentful and Retool
- Zero vendor lock-in: data stays in your standard SQL tables, readable by any other tool
- Flows: no-code/low-code automation builder (like Zapier or n8n, built into Directus)
- Extensions: custom endpoints, interfaces, displays, modules via npm packages
- Alternatives: Strapi (opinionated schema builder), Payload CMS (code-first TypeScript), KeystoneJS, Hasura (GraphQL-first)
Why Directus Instead of Strapi?
The key difference: Strapi creates its own tables in a Directus-like way — you build content types in Strapi and it creates the database structure. Directus wraps your existing database and adds an API and admin UI on top.
Choose Directus if:
- You have an existing PostgreSQL database and want an instant API + admin panel
- Your team includes non-technical content editors who need a clean UI
- You need to manage relational data visually (not just content blobs)
- You want to avoid a separate CMS database — Directus uses your app's DB
Choose Strapi if:
- You're starting fresh with no existing database
- You prefer a more opinionated CMS with a content-type builder workflow
- You need a plugin marketplace with pre-built integrations
Prerequisites
- Docker + Docker Compose
- PostgreSQL 14+ (Directus also supports MySQL 8+, SQLite, MSSQL, Oracle)
- VPS: 1 vCPU / 2GB RAM minimum (Hetzner CX21 at €3.79/mo)
- Domain with SSL for production
Docker Compose Setup
Create /opt/directus/docker-compose.yml:
version: '3.8'
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: directus
POSTGRES_USER: directus
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U directus"]
interval: 10s
cache:
image: redis:7-alpine
restart: unless-stopped
volumes:
- ./redis:/data
directus:
image: directus/directus:latest
restart: unless-stopped
ports:
- "127.0.0.1:8055:8055"
depends_on:
db:
condition: service_healthy
environment:
SECRET: ${SECRET}
DB_CLIENT: pg
DB_HOST: db
DB_PORT: 5432
DB_DATABASE: directus
DB_USER: directus
DB_PASSWORD: ${DB_PASSWORD}
CACHE_ENABLED: "true"
CACHE_STORE: redis
REDIS: redis://cache:6379
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
# File storage (local or S3)
STORAGE_LOCATIONS: local
STORAGE_LOCAL_DRIVER: local
STORAGE_LOCAL_ROOT: ./uploads
# For S3:
# STORAGE_LOCATIONS: s3
# STORAGE_S3_DRIVER: s3
# STORAGE_S3_KEY: ${S3_KEY}
# STORAGE_S3_SECRET: ${S3_SECRET}
# STORAGE_S3_BUCKET: ${S3_BUCKET}
# STORAGE_S3_REGION: ${S3_REGION}
PUBLIC_URL: https://cms.example.com
# Email
EMAIL_TRANSPORT: smtp
EMAIL_FROM: cms@example.com
EMAIL_SMTP_HOST: smtp.resend.com
EMAIL_SMTP_PORT: 587
EMAIL_SMTP_USER: resend
EMAIL_SMTP_PASSWORD: ${SMTP_PASSWORD}
volumes:
- ./uploads:/directus/uploads
- ./extensions:/directus/extensions
volumes:
postgres:
redis:
Create .env:
DB_PASSWORD=strong-db-password
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=strong-admin-password
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
SECRET=your-secret-key-here
SMTP_PASSWORD=your-smtp-api-key
docker compose up -d
# Access at http://localhost:8055
Connecting to an Existing Database
Directus's unique power is wrapping an existing database. To use it with your app's database instead of creating a new one:
directus:
environment:
DB_CLIENT: pg
DB_HOST: your-existing-db-host
DB_PORT: 5432
DB_DATABASE: your_app_database
DB_USER: directus_readonly_user # or a limited-permission user
DB_PASSWORD: ${APP_DB_PASSWORD}
On first startup, Directus creates its own system tables (directus_*) in your database but doesn't touch your existing tables. Navigate to Settings → Data Model to see your existing tables listed. Click any table to enable it as a Directus collection — instantly getting a REST API and admin UI for that table.
This is enormously useful for adding a content editing interface to an existing application without building a custom admin panel.
REST API Usage
// Fetch published articles
const response = await fetch('https://cms.example.com/items/articles?' + new URLSearchParams({
'filter[status][_eq]': 'published',
'fields': 'id,title,slug,content,date_created,author.name,author.avatar',
'sort': '-date_created',
'limit': '10',
'offset': '0',
}), {
headers: {
Authorization: `Bearer ${process.env.DIRECTUS_TOKEN}`,
},
})
const { data, meta } = await response.json()
// Create an item
await fetch('https://cms.example.com/items/articles', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DIRECTUS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'New Article',
slug: 'new-article',
content: 'Article content...',
status: 'draft',
}),
})
GraphQL API
Enable GraphQL in Directus settings → GraphQL is enabled by default:
query GetArticles($limit: Int!, $offset: Int!) {
articles(
filter: { status: { _eq: "published" } }
sort: ["-date_created"]
limit: $limit
offset: $offset
) {
id
title
slug
content
date_created
author {
name
avatar {
id
}
}
}
articles_aggregated(filter: { status: { _eq: "published" } }) {
count { id }
}
}
Directus Flows (Automation)
Flows is Directus's built-in automation engine — trigger actions on data events without leaving the admin UI:
Example: Send Slack notification when a new article is published
- Settings → Flows → Create Flow
- Trigger: Event Hook →
items.updateon collectionarticleswhenstatuschanges topublished - Operation 1: Read Data — fetch the full article record
- Operation 2: Webhook — POST to
https://hooks.slack.com/services/your-webhookwith{"text": "New article published: {{$last.title}}"}
Flows supports conditional logic, loops, transform operations, and running custom code snippets. For complex automation, it's not as powerful as n8n but covers most CMS workflow needs without an external tool.
Roles and Permissions
Directus's permission system is field-level — you can control which users can read/create/update/delete individual fields:
Settings → Roles & Permissions → Create Role: Editor
articles: Read all, Create (status: draft only), Update own (except status field)media: Read all, Create
Public role (unauthenticated API access):
articles: Read wherestatus = published, fields:id, title, slug, content, date_created- All other collections: No access
This enables a fully public REST API for your frontend while keeping draft content and admin data private.
Webhooks for Cache Invalidation
Configure Directus webhooks to trigger Next.js ISR or CDN cache purges when content changes:
Settings → Webhooks → Create Webhook:
- Name:
Next.js Revalidate - Method:
POST - URL:
https://your-site.com/api/revalidate - Actions:
items.create,items.update,items.delete - Collections:
articles,pages(whichever collections your frontend uses) - Headers:
{ "Authorization": "Bearer your-revalidation-secret" }
Next.js handler:
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
if (req.headers.get('authorization') !== `Bearer ${process.env.REVALIDATION_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { collection, payload } = body
// Revalidate the tag for this content type
revalidateTag(collection)
// If the item has a slug, revalidate the specific path too
if (payload?.slug) {
revalidatePath(`/${collection}/${payload.slug}`)
}
return NextResponse.json({ revalidated: true })
}
This gives you incremental static regeneration — your Next.js pages are statically generated at build time and automatically updated whenever editors publish changes in Directus.
Extensions
Directus supports custom extensions for interfaces, displays, layouts, modules, and hooks:
# Install extension from npm
cd /opt/directus/extensions
npm install directus-extension-wpslug # WordPress-style slugs
npm install directus-extension-tree # hierarchical tree view
npm install @directus/extension-sdk # for building custom extensions
# Restart to load
docker compose restart directus
Custom server-side hooks (in /opt/directus/extensions/hooks/my-hook/index.js):
export default ({ action }) => {
action('items.create', ({ collection, key, payload }) => {
if (collection === 'articles') {
console.log(`New article created: ${key}`)
// Send to search index, CDN purge, etc.
}
})
}
Nginx Configuration
server {
listen 443 ssl http2;
server_name cms.example.com;
ssl_certificate /etc/letsencrypt/live/cms.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cms.example.com/privkey.pem;
client_max_body_size 50m;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
Backup Strategy
#!/bin/bash
DATE=$(date +%Y%m%d-%H%M)
BACKUP_DIR=/opt/backups/directus
mkdir -p $BACKUP_DIR
# Database
docker compose exec -T db pg_dump -U directus directus | gzip > "$BACKUP_DIR/db-$DATE.sql.gz"
# Uploads (if not using S3)
tar -czf "$BACKUP_DIR/uploads-$DATE.tar.gz" /opt/directus/uploads/
# Keep 14 days
find $BACKUP_DIR -name "*.gz" -mtime +14 -delete
If you configured S3 storage, only the database backup is needed — uploads are already in object storage with versioning enabled. Test your backup restoration process at least once to verify the dump is complete and restores cleanly. A corrupt or empty backup is worse than no backup — you'd be unaware of the failure until disaster strikes.
Troubleshooting Common Issues
Relations not loading (nested fields returning null):
The fields query parameter must explicitly request nested fields:
# Wrong — returns author as null
/items/articles?fields=title,author
# Correct — returns author as object with name
/items/articles?fields=title,author.name,author.avatar.id
Permission denied for public API: Verify the Public role has read permission on the collection. In Settings → Roles → Public, check that the collection is enabled and the field filters aren't overly restrictive. Directus defaults to no access for the public role.
Admin panel slow on large datasets: Add appropriate indexes to your database for fields you sort/filter by:
CREATE INDEX idx_articles_status_created ON articles(status, date_created DESC);
CREATE INDEX idx_articles_slug ON articles(slug);
Directus also supports defining CONTENT_SECURITY_POLICY_DIRECTIVES for iframe embedding in third-party tools.
File uploads failing:
Check that the uploads volume is mounted correctly and writable:
docker compose exec directus ls -la /directus/uploads
# Should show the uploads directory writable by the node user
For S3 uploads, verify CORS is configured on your bucket to allow requests from your Directus domain.
Cost vs Contentful and Airtable
| Service | Monthly Cost | Records/API Limit |
|---|---|---|
| Contentful Micro | $300/month | 2M API calls |
| Airtable Pro | $20/user/month | 50K rows/base |
| Retool | $12/user/month | Cloud-only |
| Directus Cloud | $15-99/month | Varies |
| Directus Self-Hosted | ~€5/month VPS | Unlimited |
Directus self-hosted is particularly compelling as an Airtable replacement for structured data management — unlimited rows, API calls, and users on a single €5/month VPS.
Upgrading Directus
Directus releases weekly — check the changelog:
cd /opt/directus
docker compose pull directus
docker compose up -d --force-recreate directus
# Directus runs migrations automatically on startup
docker compose logs directus | grep -i "migrated\|error"
Methodology
- Directus documentation: docs.directus.io
- GitHub: github.com/directus/directus (28K+ stars)
- Tested with Directus 11.x, PostgreSQL 16, Docker Compose v2
Browse open source headless CMS and data platform alternatives on OSSAlt.
Related: How to Self-Host Strapi — Headless CMS 2026 · Best Open Source Alternatives to Firebase 2026