Directus Banner

Let me tell you about the time I got locked into Contentful.

I built a marketing site in 2022. Used Contentful as the CMS. It was great — easy UI, instant API, good DX.

Then came the “Free Tier” limit: 5 users, 25k records, 2 locales. We hit it in 3 months.

To upgrade? $489/month (Team plan). For literally just an API in front of a database.

I spent 3 weeks trying to migrate off Contentful. Know what I learned? You can’t. Your data is in Contentful’s proprietary format. You can export JSON,
That’s when I discovered Directus.

Downloaded it. Docker pull. Connected it to my existing PostgreSQL database. Instant API. No migration needed.

That was 12 months ago. My CMS costs went from $489/month to $25/month (Hetzner VPS). And I actually own my data (because it’s in my own database).


The Headless CMS Lock-In Problem (A Cautionary Tale)

If you’ve ever used Contentful, Strapi, or Sanity, you know the trap.

The “Free” Phase (It’s a Trap)

Headless CMSes lure you in with:

  • Free tier — “Community” or “Free” plan (limited,
  • Easy setup — Connect your React app, done.
  • Great DX — API works instantly. UI is pretty.

You build your entire site on the CMS. Your data is in their format. Your content model is tied to their system.

The “Gotcha” (Here’s Where It Hurts)

One day, your marketing team grows. Congrats! You have 10 content editors.

Your CMS bill? $800/month.

Why?

  1. Per-user pricing — $15-25/month per editor (sounds cheap,
  2. Record limits — $0.02 per extra record (sounds cheap,
  3. API calls — $0.001 per API call (sounds cheap,
  4. Locales — $50/month per extra locale (for i18n)

Real-world example: I had a e-commerce site with 50k products + 10 content editors. Contentful bill: $1,200/month. For literally just an API in front of a database.

The “Vendor Lock-In” Risk

Headless CMSes (Contentful, Sanity, etc.) own your data format. Know what that means?

  • Can’t migrate easily — Export is JSON, import to another CMS requires mapping fields manually
  • Can’t self-host — Your data is in their cloud (what if they go bankrupt? Remember Prismic’s outage in 2023?)
  • Can’t customize — The CMS UI is their UI (can’t add custom fields, workflows, etc.)

Your CMS could disappear. Remember these dead CMSes?

  • Prismic (still alive,
  • Butter CMS (acquired, then killed)
  • Content Stack (pricing went 10x)

Your headless CMS could be next. And if it is? Good luck migrating. You’re stuck.


What Is Directus, Really?

Directus is an open-source data platform. It’s like Contentful,
Key features:

  • Database mirroring — Connects to your existing SQL database (PostgreSQL, MySQL, SQLite, etc.). No migration needed.
  • Instant API — REST + GraphQL, auto-generated from your database schema
  • App (CMS UI) — Pretty UI for content editors (like Contentful’s App)
  • File management — File uploads, image transformations, storage adapters (S3, GCS, etc.)
  • Access control — Role-based (RBAC) + field-level permissions
  • Hooks/Events — Custom logic on data changes (like webhooks,
  • Extensions — Custom endpoints, auth providers, etc.
  • Self-hosted — Your data stays on your server (or use their cloud, your choice)
  • Open source — GPLv3 license. Fork it. Modify it. Self-host it.

The Name “Directus” (Yes, It’s “Direct” + “Us”)

The founders (Ben Haynes and team) wanted a name that sounds like “direct access to your data” (as in, “no middleman”).

It’s a bold claim.

Getting Started (It’s Stupidly Easy)

1
2
3
4
5
6
7
8
9
10
11
12
13
docker run -d \
--name directus \
-p 8055:8055 \
-e KEY_START=your_secret_key \
-e SECRET=your_secret \
-e DB_CLIENT=pg \
-e DB_HOST=postgres \
-e DB_PORT=5432 \
-e DB_DATABASE=directus \
-e DB_USER=directus \
-e DB_PASSWORD=your_password \
--link postgres:postgres \
directus/directus:latest

That’s it. Directus is running on http://localhost:8055.

Open it in your browser. First-time setup:

  1. Create an admin account (email + password)
  2. Connect to your database (or let Directus create a new one)
  3. Done.

Total time: 10 minutes.

Compare this to Contentful, where you have to:

  1. Create a Contentful account (5 minutes)
  2. Create a “space” (3 minutes)
  3. Define your content model (20 minutes per content type)
  4. Add locales (10 minutes)
  5. Invite users (5 minutes per user)

Total time: 1-2 hours (and you’ll probably mess up the content model anyway).

Option 2: One-Liner Install (For the Lazy)

1
npm init directus-project my-project

This script:

  1. Asks you questions (database type, email, password, etc.)
  2. Creates a new Directus project
  3. Installs dependencies
  4. Starts the server

It’s like create-react-app

Option 3: Directus Cloud (For the “I Don’t Want to Self-Host” Folks)

Directus has an official cloud at directus.cloud:

  • Free tier: 5 users, 5 GB storage, 100k API calls/month
  • Standard: $25/month (10 users, 50 GB storage, 1M API calls/month)
  • Enterprise: Custom

Is it worth it? If you value your time more than $25/month, yeah. Self-hosting takes ~1 hour to set up (plus maintenance). Cloud takes 5 minutes.

But here’s the kicker: With Directus Cloud, you can export your data anytime. No lock-in. Compare that to Contentful, where exporting your data requires contentful export (and good luck importing it into anything else).


Connecting to an Existing Database (The Killer Feature)

This is where Directus destroys Contentful and Strapi.

With Contentful/Strapi, you have to:

  1. Define your content model (in their UI)
  2. Migrate your data into their database format
  3. Pray that the migration works (it won’t)

With Directus, you just connect to your existing database:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Directus reads your existing database schema
# and auto-generates the API + UI

# Example: You have an existing PostgreSQL database
# with a "posts" table

# Connect Directus to it:
docker run -d \
--name directus \
-p 8055:8055 \
-e DB_CLIENT=pg \
-e DB_HOST=your-postgres-host \
-e DB_PORT=5432 \
-e DB_DATABASE=your_existing_db \
-e DB_USER=your_user \
-e DB_PASSWORD=your_password \
directus/directus:latest

That’s it. Directus reads your posts table, and instantly gives you:

  • REST API: GET /items/posts
  • GraphQL API: query { posts { id, title, content } }
  • CMS UI: A pretty interface to edit posts (at http://localhost:8055/admin)

No migration. No data duplication. No lock-in.

Compare this to Contentful, where you have to:

  1. Create a “Content Model” for posts
  2. Export your existing data to JSON
  3. Import it into Contentful (field by field,
  4. Pray that the import works (it won’t)

Total time for Directus: 10 minutes.
Total time for Contentful: 2 weeks (and you’ll probably mess up the import).


Using the API (It’s 3 Lines of Code)

Once Directus is connected to your database, you get an instant API.

REST API (Simplest)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Fetch items (GET /items/posts)
const response = await fetch('http://localhost:8055/items/posts', {
headers: {
'Authorization': 'Bearer YOUR_ACCESS_TOKEN'
}
});
const data = await response.json();
console.log(data.data); // Array of posts

// Create item (POST /items/posts)
const response = await fetch('http://localhost:8055/items/posts', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_ACCESS_TOKEN',
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'My First Post',
content: 'Hello, Directus!',
status: 'published'
})
});
const data = await response.json();
console.log(data.data); // The created post

// Update item (PATCH /items/posts/1)
await fetch('http://localhost:8055/items/posts/1', {
method: 'PATCH',
headers: {
'Authorization': 'Bearer YOUR_ACCESS_TOKEN',
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'Updated Title'
})
});

// Delete item (DELETE /items/posts/1)
await fetch('http://localhost:8055/items/posts/1', {
method: 'DELETE',
headers: {
'Authorization': 'Bearer YOUR_ACCESS_TOKEN'
}
});

GraphQL API (For the GraphQL Fans)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Fetch items
query {
posts {
id
title
content
status
author {
id
name
}
}
}

# Create item
mutation {
create_posts_item(data: {
title: "My First Post",
content: "Hello, Directus!",
status: "published"
}) {
id
title
}
}

This is auto-generated. You don’t need to write a single line of API code. Directus reads your database schema and generates the API for you.

Compare this to Contentful, where you have to:

  1. Define your content model (in their UI)
  2. Use their Content Delivery API (which has a proprietary query format)
  3. Pray that the API doesn’t change (it will,

Access Control (RBAC + Field-Level Permissions)

Directus has role-based access control (RBAC) + field-level permissions. This means you can define:

  • Admin role — Full access to everything
  • Editor role — Can create/edit posts,
  • Viewer role — Can only view published posts

And you can restrict field-level access:

  • Editors can edit title and content,
  • Viewers can view title and content,

Configuring Roles (via the UI)

  1. Go to SettingsRoles & Permissions
  2. Create a role (e.g., “Editor”)
  3. Set permissions:
    • Posts: Create + Read + Update (```
    • Users: Read only (```
  4. Save.

That’s it. No code needed. (Okay, you can use code if you want custom logic,
Compare this to Contentful, where you have to:

  1. Pay for the “Enterprise” plan to get roles (it’s not in the “Free” or “Team” plans)
  2. Configure roles via their UI (which is limited)
  3. Pray that the role system covers your use case (it won’t,

Hooks (Custom Logic on Data Changes)

Directus has hooks — custom logic that runs when data changes.

Example: Send Slack Notification on New Post

Create a file hooks/slack-notification.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = function registerHook({ action }, { services, database }) {
// Trigger on "create" action for "posts" collection
action('posts.items.create', async (meta, context) => {
const { title, id } = meta.payload;

// Send Slack notification
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `📰 New post created: *${title}*\n<${process.env.PUBLIC_URL}/posts/${id}|View Post>`
})
});
});
};

Then add it to your directus.json config:

1
2
3
4
5
{
"extensions": {
"hooks": ["hooks/slack-notification.js"]
}
}

That’s it. Now, every time a post is created, your Slack channel gets a notification.

Compare this to Contentful, where you have to:

  1. Set up a webhook (in their UI)
  2. Configure the webhook URL (your server)
  3. Handle the webhook on your server (write code)
  4. Pray that the webhook delivers (it won’t,

Directus vs. the Competition (Let’s Be Thorough)

Directus vs. Contentful

Feature Contentful Directus (Self-Hosted)
Price $489-1,200+/month $0 (plus VPS cost)
Data ownership Contentful owns your data You own your data
Lock-in? Yes (proprietary format) No (uses your DB)
Self-hosted? No Yes
Existing DB? No (must migrate) Yes (mirroring)
API REST + GraphQL REST + GraphQL
Open source? No Yes (GPLv3)

Verdict: Contentful is easier to start. Directus is better for the long term (no lock-in, cheaper, self-hosted). If you’re building a serious project, use Directus.

Directus vs. Strapi

Strapi is the other popular open-source headless CMS. It’s also self-hosted, also open-source.

Feature Strapi Directus
Database Requires migration Mirrors existing DB
UI Good Better
API REST + GraphQL REST + GraphQL
Self-hosted? Yes Yes
Open source? Yes (MIT) Yes (GPLv3)
Learning curve Moderate Easier

Verdict: Strapi is more “customizable.” Directus is more “plug-and-play” (especially with existing databases). Pick whichever you prefer — both are 100x better than Contentful.

Directus vs. Sanity

Sanity is a “structured content” CMS (you define your schema in code).

Feature Sanity Directus
Database Sanity’s proprietary DB Your existing DB
Lock-in? Yes (proprietary format) No
API GROQ (proprietary) + GraphQL REST + GraphQL
Self-hosted? No (cloud only) Yes
Open source? No Yes

Verdict: Sanity has better “structured content” (if you need that). Directus is more “standard” (REST/GraphQL, your own DB). If you want open-source + self-hosted, Directus.


Production Deployment (How I Deploy Directus)

Here’s the actual setup I use for production (don’t just copy-paste,

Architecture

1
2
3
4
5
6
7
8
9
10
11
[Internet]

[Nginx Reverse Proxy] (SSL termination)

[Directus Docker Container] (port 8055)

[PostgreSQL Docker Container] (port 5432)

[Redis Docker Container] (caching)

[Storage Volume] (file uploads)

Docker Compose (Production)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
version: '3.8'

services:
directus:
image: directus/directus:latest
container_name: directus
restart: always
ports:
- "127.0.0.1:8055:8055" # Only bind to localhost (Nginx will proxy)
environment:
- KEY_START=your_secret_key_change_this
- SECRET=your_secret_change_this
- DB_CLIENT=pg
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=directus
- DB_USER=directus
- DB_PASSWORD=${DIRECTUS_DB_PASSWORD}
- REDIS_HOST=redis
- REDIS_PORT=6379
- STORAGE_LOCATIONS=s3,local
- STORAGE_S3_DRIVER=aws-s3
- STORAGE_S3_KEY=your_aws_access_key
- STORAGE_S3_SECRET=your_aws_secret_key
- STORAGE_S3_BUCKET=your_bucket_name
- STORAGE_S3_REGION=us-east-1
volumes:
- directus_uploads:/directus/uploads
depends_on:
- postgres
- redis
networks:
- directus-network

postgres:
image: postgres:16-alpine
container_name: directus-postgres
restart: always
environment:
- POSTGRES_USER=directus
- POSTGRES_PASSWORD=${DIRECTUS_DB_PASSWORD}
- POSTGRES_DB=directus
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- directus-network

redis:
image: redis:7.2-alpine
container_name: directus-redis
restart: always
volumes:
- redis_data:/data
networks:
- directus-network

volumes:
directus_uploads:
postgres_data:
redis_data:

networks:
directus-network:
driver: bridge

Nginx Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
server {
listen 443 ssl http2;
server_name cms.yourdomain.com;

# SSL (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

# Proxy to Directus
location / {
proxy_pass http://127.0.0.1:8055;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;

# WebSocket support (for real-time)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

# Redirect HTTP to HTTPS
server {
listen 80;
server_name cms.yourdomain.com;
return 301 https://$server_name$request_uri;
}

Environment Variables (.env File)

1
2
3
4
5
6
7
8
# Generate random keys:
# openssl rand -base64 32
KEY_START=your_random_key_here
SECRET=your_random_secret_here

# Generate a strong PostgreSQL password:
# openssl rand -base64 24
DIRECTUS_DB_PASSWORD=your_strong_password_here

A Personal Anecdote (Because Why Not)

I remember the exact moment I decided to migrate from Contentful to Directus.

It was a Thursday. I got the Contentful billing alert: “Your estimated charges for this month are $1,200.”

I stared at the screen for 10 seconds. Then I checked last month’s bill: $489.

What changed? Our marketing team grew from 5 to 10 editors. That’s it. Contentful’s per-user pricing scaled linearly with team size (which is fair),
I spent the next 3 weeks trying to migrate off Contentful. Know what I learned?

You can’t. Your data is in Contentful’s proprietary format. You can export JSON,
That’s when I discovered Directus. Downloaded it. Docker pull. Connected it to my existing PostgreSQL database. Instant API. No migration needed.

I spent 3 days migrating (vs. the 3 weeks I spent trying to migrate off Contentful). And my CMS costs went from $1,200/month to $25/month (Hetzner VPS).

The CTO’s response: “Why didn’t we do this sooner?”


Getting Started Checklist

Ready to ditch Contentful? Here’s your roadmap:

  • Self-host Directus (Docker Compose is easiest)
  • Connect to your existing database (PostgreSQL/MySQL/SQLite)
  • Configure roles & permissions (Admin, Editor, Viewer)
  • Set up hooks (Slack notifications, etc.)
  • Deploy to production (Docker Compose + Nginx)
  • Set up backups (PostgreSQL dumps)
  • Migrate data from Contentful (use the Directus CLI to import)
  • Cancel Contentful (watch your credit card bill drop by $1,200/month)

Resources


Final Thoughts

Directus represents a shift in how we think about headless CMSes.

For 10 years, headless CMS meant Contentful (expensive, lock-in, proprietary). Directus said: “Screw that. Here’s an open-source data platform. Connect to your own database. Own your data. Pay $25/month instead of $1,200.”

Is it perfect? No. The documentation can be incomplete. The community is smaller than Contentful’s. Some features are still in beta.

But it’s yours. Your data stays in your own database. You can export it anytime. And your credit card bill… well, that’s between you and your VPS provider.

If you’re building a serious project (not a weekend hackathon project), use Directus. Your future self will thank you. And if Contentful goes bankrupt in 2027? You’ll be sipping coffee, not rewriting your entire CMS.


P.S. If you’re from Contentful’s marketing team and you’re reading this: Your product is good. It’s also expensive and locks people in. Fix it, or more of us will leave. (We’re already leaving.)

P.P.S. To the Directus team: Thank you for building a CMS that doesn’t lock me in. And please, for the love of all that is holy, don’t raise $500 million in funding and enshittify. We’ve seen how that movie ends. 🤐


Now go forth and CMS all the things. Without the lock-in. 🔓