Headless CMS adoption exploded over the last three years because teams are tired of the old WordPress trade-off: a bloated monolith on one side, or a $500/month SaaS bill on the other. The middle ground — a lightweight, API-first backend you actually own — is where Strapi and Directus live.
Both are open-source, Node.js, MIT-licensed, and run perfectly well on a single small VPS. Both expose REST and GraphQL automatically. Both handle media, roles, webhooks, and multi-environment workflows. And both will cost you roughly €36/month all-in on DanubeData, vs. $300-$500/month for Contentful or Sanity at comparable scale.
This guide walks through the decision (Strapi vs Directus), the hosting recipe (Docker Compose + managed Postgres + S3), and the production concerns (backups, migrations, webhook-driven frontend deploys). If you are moving off WordPress, Contentful, or Sanity, or starting a Next.js/Nuxt/SvelteKit project in 2026, this is the reference.
What Is a Headless CMS — And Why It Beat WordPress
A traditional CMS like WordPress bundles the content database, the admin UI, and the public-facing rendering into one PHP monolith. You pick a theme, edit in the wp-admin, and your visitors hit the same server that the editors do. It works, but it couples your content model to your presentation layer — and it makes multi-channel publishing (web + mobile app + smart TV + AI agent) painful.
A headless CMS strips the rendering layer out entirely. Editors work in a modern admin panel. The CMS exposes content as JSON over REST and GraphQL. Your frontend — Next.js, Nuxt, Astro, SvelteKit, a native app, whatever — consumes that JSON and renders however it likes. Static sites, SSR, ISR, edge rendering, push to a mobile app: all from the same source of truth.
| Concern | WordPress (traditional) | Headless CMS (Strapi/Directus) |
|---|---|---|
| Frontend | PHP themes, tightly coupled | Any framework, any channel |
| Performance | DB query per request | Static/edge-cached JSON |
| Security surface | Huge (plugins, themes, wp-admin exposed) | API-only, admin can be IP-locked |
| Multi-channel | Painful plugin hacks | Native (JSON is JSON) |
| Developer experience | PHP, legacy patterns | TypeScript, modern tooling |
| Editor experience | Familiar, Gutenberg | Modern, component-based |
The one column WordPress still arguably wins is editor familiarity. Both Strapi and Directus have closed that gap dramatically in the last two years — Directus in particular has an admin that non-technical editors pick up in about 15 minutes.
Strapi vs Directus: The Honest Comparison
These two projects look similar from a distance and are genuinely different up close. Picking the wrong one costs you a rewrite six months later, so spend ten minutes here.
Strapi in one paragraph
Strapi is content-model first. You define your content types inside the Strapi admin (or in code under src/api/), and Strapi owns the underlying database schema. It manages migrations, creates the tables, and regenerates the REST/GraphQL endpoints for you. Think of it as a full-stack framework for editorial content. The plugin marketplace is large (SEO, i18n, Stripe, Algolia, GraphQL, sitemap, email providers), and the v5 release matured the TypeScript story considerably.
Directus in one paragraph
Directus is database first. You point it at any existing SQL database — Postgres, MySQL, SQLite, MSSQL, Oracle — and Directus introspects the schema and wraps it with a beautiful admin UI, REST, and GraphQL. It does not own the schema; it reflects whatever is already there. That makes it perfect for legacy databases, ERP front-ends, internal tools, or any scenario where "the database is the source of truth and the CMS is the editor." The admin UI is widely considered the best in the category.
Side-by-side
| Feature | Strapi v5 | Directus 11 |
|---|---|---|
| Mental model | Content-type framework; CMS owns schema | Database wrapper; DB owns schema |
| Runtime | Node.js 20+, Koa | Node.js 20+, Express |
| Databases | Postgres, MySQL, MariaDB, SQLite | Postgres, MySQL, MariaDB, SQLite, MSSQL, Oracle, CockroachDB |
| REST API | Auto-generated | Auto-generated |
| GraphQL | Plugin (official) | Built-in |
| Admin UI quality | Good, improved in v5 | Excellent (most praised in category) |
| Plugin ecosystem | Large marketplace | Extensions system, smaller ecosystem |
| Permissions | Role-based, collection-level | Role-based + field-level + item-level conditions |
| Flows/automation | Webhooks + lifecycle hooks | Webhooks + visual Flows engine |
| i18n | First-class, polished | Translations collection pattern |
| Draft/publish | Built-in draft workflow | Status field pattern |
| License | MIT (Enterprise edition has paid features) | BSL 1.1 (free for self-host under revenue threshold) |
| RAM (idle) | ~400-600 MB | ~250-400 MB |
| Best for | New editorial sites, blogs, marketing sites, Next.js/Nuxt frontends | Legacy DBs, internal tools, ERP/CRM front-ends, admin-heavy data apps |
The 30-second decision rule
- Greenfield editorial project (blog, docs, marketing site, learning platform) with a Next.js/Nuxt/Astro frontend → Strapi.
- Existing database you need to put an admin panel on, or heavy data-entry internal tool → Directus.
- Complex permissions (field-level, row-level) that depend on the editor's role → Directus.
- Large plugin needs (SEO, i18n, payments, search) out of the box → Strapi.
Everything else is taste. Both will work. Both are maintained. Both have been in production at scale at real companies since 2020.
Headless CMS Market: Self-Hosted vs SaaS Cost
This is where the ROI of self-hosting becomes obvious. Below is the price of each option at a mid-sized site — say 10 content types, 3 editors, 50 GB of media, 1M API requests per month.
| Product | Tier | Monthly Cost | Key Limits | Data Location |
|---|---|---|---|---|
| Contentful | Lite / Premium | $300-$489+/mo | Capped API calls, seats, roles; overages get expensive | US (default) |
| Sanity | Growth | $99+/mo | Per-user billing, CDN bandwidth limits, usage-based add-ons | US/EU (paid) |
| Storyblok | Entry / Business | €99-€599/mo | Capped traffic/users; visual editor is nice but you pay for it | EU (Germany) |
| Hygraph (ex-GraphCMS) | Growth | $299+/mo | API requests, assets, users | EU |
| Strapi (self-hosted) | Community | ~€36/mo all-in | Unlimited everything — you own it | Wherever you host (EU on DanubeData) |
| Directus (self-hosted) | Community | ~€36/mo all-in | Unlimited everything — you own it | Wherever you host (EU on DanubeData) |
The annual delta is brutal. Contentful at $489/month is $5,868/year. Self-hosting on DanubeData is ~€432/year. That is a ~€5,000 swing for functionally equivalent output, and you get GDPR-native EU hosting, no per-seat pricing, and full data sovereignty.
The Recommended DanubeData Recipe
For any serious Strapi or Directus deployment, the right shape is three components: a VPS for the CMS process, a managed database (so you do not have to babysit Postgres), and S3 for media assets (so uploads are not trapped on a single VPS disk and backups are trivial).
| Component | Why | DanubeData SKU | Price |
|---|---|---|---|
| VPS (DD Small) | 2 vCPU, 8 GB RAM, NVMe — fits Strapi/Directus with room to spare | DD Small | €12.49/mo |
| Managed Postgres | Automated backups, snapshots, replica-ready; no DBA work | Managed Postgres Starter | €19.99/mo |
| S3 Object Storage | 1 TB included, S3-compatible, for media uploads and backups | Object Storage | €3.99/mo |
| Total | €36.47/mo |
For smaller personal sites the DD Micro (€7.49/mo, 4 GB RAM) handles a single Strapi or Directus fine, especially with media on S3. But once you add a staging environment, i18n content, or a plugin or two, the 8 GB of DD Small pays for itself.
DanubeData gives new accounts €50 in credit, which covers roughly six weeks of this setup at zero cost while you evaluate.
Step 1: Provision the Infrastructure
- Sign in at danubedata.ro and create the three resources: a VPS (DD Small, Ubuntu 24.04), a managed Postgres instance, and an S3 bucket.
- For the Postgres instance, note the hostname, port, database name, username, and password. DanubeData exposes it over the internal cluster DNS (e.g.,
my-db.tenant-xxx.svc.cluster.local) and also over a public endpoint for external VPS clients. - For the S3 bucket, create an access key pair in the dashboard. Note the endpoint URL (e.g.,
https://new-s3.danubedata.ro), the region (fsn1), the bucket name, the access key, and the secret key. - SSH into your VPS and do the standard hardening:
ssh root@YOUR_VPS_IP
apt update && apt upgrade -y
apt install -y curl git ufw
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
curl -fsSL https://get.docker.com | sh
apt install -y docker-compose-plugin
systemctl enable docker
Create an A record for your CMS hostname (e.g., cms.example.com) pointing at the VPS public IP. Verify with dig cms.example.com +short before you start TLS issuance.
Step 2A: Deploy Strapi with Docker Compose
Pick this branch if you chose Strapi above. If you picked Directus, skip ahead.
mkdir -p /opt/strapi && cd /opt/strapi
# Generate four secrets Strapi needs
openssl rand -base64 32 # APP_KEYS item 1
openssl rand -base64 32 # APP_KEYS item 2
openssl rand -base64 16 # API_TOKEN_SALT
openssl rand -base64 16 # ADMIN_JWT_SECRET
openssl rand -base64 16 # JWT_SECRET
openssl rand -base64 16 # TRANSFER_TOKEN_SALT
Now write the Compose file and the env file:
cat > .env << 'EOF'
HOST=0.0.0.0
PORT=1337
APP_KEYS=key1_from_openssl,key2_from_openssl
API_TOKEN_SALT=salt_from_openssl
ADMIN_JWT_SECRET=jwt_from_openssl
TRANSFER_TOKEN_SALT=transfer_from_openssl
JWT_SECRET=jwt2_from_openssl
# Database (managed Postgres on DanubeData)
DATABASE_CLIENT=postgres
DATABASE_HOST=your-managed-pg-host.danubedata.ro
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=YOUR_DB_PASSWORD
DATABASE_SSL=true
# S3 (DanubeData Object Storage)
AWS_ACCESS_KEY_ID=YOUR_S3_ACCESS_KEY
AWS_ACCESS_SECRET=YOUR_S3_SECRET
AWS_REGION=fsn1
AWS_BUCKET=your-bucket-name
AWS_ENDPOINT=https://new-s3.danubedata.ro
# Public URL
PUBLIC_URL=https://cms.example.com
ADMIN_PATH=/admin
EOF
cat > docker-compose.yml << 'EOF'
services:
strapi:
image: strapi/strapi:5-alpine
restart: always
env_file: .env
volumes:
- ./src:/opt/app/src
- strapi-uploads:/opt/app/public/uploads
expose:
- "1337"
depends_on:
- caddy
caddy:
image: caddy:2-alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
volumes:
strapi-uploads:
caddy-data:
caddy-config:
EOF
cat > Caddyfile << 'EOF'
cms.example.com {
reverse_proxy strapi:1337
encode gzip
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
}
}
EOF
docker compose up -d
docker compose logs -f strapi
First boot takes about two minutes while Strapi runs migrations against your managed Postgres. When you see To access the server, visit https://cms.example.com/admin, create the first admin user, and you are in.
Configure S3 upload in Strapi
Install the official S3 provider inside the container (or add it to package.json if you maintain a custom Strapi image):
docker compose exec strapi npm install @strapi/provider-upload-aws-s3
Create config/plugins.js in your Strapi project:
module.exports = ({ env }) => ({
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
s3Options: {
credentials: {
accessKeyId: env('AWS_ACCESS_KEY_ID'),
secretAccessKey: env('AWS_ACCESS_SECRET'),
},
region: env('AWS_REGION'),
endpoint: env('AWS_ENDPOINT'),
params: { Bucket: env('AWS_BUCKET') },
forcePathStyle: true,
},
},
actionOptions: { upload: {}, uploadStream: {}, delete: {} },
},
},
});
Redeploy with docker compose restart strapi. Uploaded media now lives in your S3 bucket, served over the public endpoint. The local strapi-uploads volume stays as a fallback for legacy files.
Step 2B: Deploy Directus with Docker Compose
Pick this branch if you chose Directus.
mkdir -p /opt/directus && cd /opt/directus
openssl rand -hex 32 # KEY
openssl rand -hex 32 # SECRET
cat > docker-compose.yml << 'EOF'
services:
directus:
image: directus/directus:11
restart: always
expose:
- "8055"
volumes:
- directus-uploads:/directus/uploads
- directus-extensions:/directus/extensions
environment:
KEY: "YOUR_KEY_FROM_OPENSSL"
SECRET: "YOUR_SECRET_FROM_OPENSSL"
DB_CLIENT: "pg"
DB_HOST: "your-managed-pg-host.danubedata.ro"
DB_PORT: "5432"
DB_DATABASE: "directus"
DB_USER: "directus"
DB_PASSWORD: "YOUR_DB_PASSWORD"
DB_SSL__REJECT_UNAUTHORIZED: "false"
ADMIN_EMAIL: "admin@example.com"
ADMIN_PASSWORD: "change-me-on-first-login"
PUBLIC_URL: "https://cms.example.com"
# S3 asset storage (DanubeData)
STORAGE_LOCATIONS: "s3"
STORAGE_S3_DRIVER: "s3"
STORAGE_S3_KEY: "YOUR_S3_ACCESS_KEY"
STORAGE_S3_SECRET: "YOUR_S3_SECRET"
STORAGE_S3_BUCKET: "your-bucket-name"
STORAGE_S3_REGION: "fsn1"
STORAGE_S3_ENDPOINT: "https://new-s3.danubedata.ro"
STORAGE_S3_FORCE_PATH_STYLE: "true"
# CORS for your frontend
CORS_ENABLED: "true"
CORS_ORIGIN: "https://www.example.com,https://example.com"
# Cache (optional, uses memory by default)
CACHE_ENABLED: "true"
CACHE_AUTO_PURGE: "true"
depends_on:
- caddy
caddy:
image: caddy:2-alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
volumes:
directus-uploads:
directus-extensions:
caddy-data:
caddy-config:
EOF
cat > Caddyfile << 'EOF'
cms.example.com {
reverse_proxy directus:8055
encode gzip
}
EOF
docker compose up -d
docker compose logs -f directus
Directus boots faster than Strapi (~30 seconds), creates its system tables in your managed Postgres, and is ready at https://cms.example.com. Log in with the ADMIN_EMAIL / ADMIN_PASSWORD you set, then rotate the password immediately under User Settings.
Step 3: Content Modeling
Both tools model content as "collections" (Strapi) or "collections" (Directus) — tables in your Postgres, essentially. The mental shift from WordPress is that there is no post default. You design the shape.
A realistic blog model (applies to both)
author— name, email, bio (rich text), avatar (media), slugcategory— name, slug, descriptiontag— name, slugarticle— title, slug, excerpt, body (rich text or blocks), hero image (media), published_at, status (draft/published), author (relation), category (relation), tags (many-to-many)site_setting— singleton with site-wide values (name, default_og_image, social links)
Strapi approach
Use the Content-Type Builder in the admin UI. Strapi writes the schema files to src/api/<name>/content-types/<name>/schema.json and regenerates the REST/GraphQL endpoints. In dev you check those files into git, deploy them, and Strapi runs migrations on boot. The admin-driven flow is faster; the file-based flow is better for multi-environment discipline.
Directus approach
Create the collections in Settings → Data Model. Directus writes directly to your Postgres schema, so the tables appear in pg_catalog immediately. For version-controlled schemas, use directus schema snapshot to export a YAML file and directus schema apply to replay it in another environment. Same idea, different mechanics.
Step 4: API Generation — REST and GraphQL Are Free
This is the magic trick both tools deliver. The moment you save a content type, these endpoints exist:
# Strapi (v5)
GET https://cms.example.com/api/articles
GET https://cms.example.com/api/articles/:documentId
POST https://cms.example.com/api/articles
GET https://cms.example.com/graphql # if GraphQL plugin installed
# Directus
GET https://cms.example.com/items/article
GET https://cms.example.com/items/article/:id
POST https://cms.example.com/items/article
POST https://cms.example.com/graphql # built-in
In Strapi you query with populate and filters query params. In Directus you use fields, filter, and deep. Both support pagination, sorting, field selection, nested relations, and aggregations out of the box. Compare to building this surface yourself in Express — you just saved six weeks of work.
Step 5: Roles, Permissions, and API Tokens
Strapi: Users & Permissions plugin gives you Public and Authenticated roles. Fine-grained collection-level permissions are set per role. For programmatic access, create API tokens under Settings → API Tokens (read-only, full-access, or custom with per-collection scopes). Tokens go in the Authorization: Bearer header.
Directus: Role system is significantly more granular. You can define permissions per collection, per field, and per item (with filter-based conditions — e.g., "editor can only update articles where author_id = $CURRENT_USER"). For programmatic access, create a "Static Token" on any user and send it as Authorization: Bearer. For short-lived access, the standard OAuth2 flow works as well.
For a public-facing frontend, the pattern is: create a dedicated "frontend" role with read-only permissions on the collections your site needs, generate a static token for that role, and put the token in your frontend build env. Do not use the admin token in production.
Step 6: Media Management with S3
You already wired up S3 in step 2. When an editor uploads a hero image, the media goes to your DanubeData bucket, and the CMS stores the URL/metadata in Postgres. A few operational notes:
- Set up a CDN in front of S3 if you serve lots of global traffic. DanubeData's S3 endpoint is fast from Europe; for global audiences, put Cloudflare or Bunny in front of the bucket for edge caching.
- Enable bucket versioning for accidental deletes. Cheap, and editors will thank you the first time someone deletes the homepage hero.
- Configure CORS on the bucket if your admin UI needs to upload via presigned URLs from the browser. Both CMSes proxy uploads through the server by default, so CORS is rarely needed, but if you add a custom file-picker it becomes relevant.
- Transform on the fly: Strapi has built-in responsive image variants (thumbnail, small, medium, large). Directus has a
/assets/:id?width=800&quality=80endpoint that crops and resizes on request. Cache aggressively.
Step 7: Webhook-Driven Deployments to Your Frontend
The canonical architecture is: CMS publishes → webhook fires → frontend rebuilds (or revalidates). Every major host supports this.
Vercel (Next.js ISR)
# Create a "Deploy Hook" in Vercel project settings
# Copy the URL (looks like https://api.vercel.com/v1/integrations/deploy/prj_xxx/yyy)
# Strapi: Settings → Webhooks → Add
# URL: the Vercel hook
# Events: entry.publish, entry.unpublish, entry.update
# Directus: Settings → Webhooks → +
# URL: the Vercel hook
# Actions: Create, Update, Delete
# Collections: article, author, category, tag, site_setting
Static site rebuilds (Next.js SSG, Astro, Nuxt generate)
Same pattern — whatever your host's build trigger URL is, paste it into the CMS webhook config. Add a small debounce if editors save rapidly: Strapi has a throttle option; Directus Flows can batch-queue in a 30-second window before firing.
On-demand ISR / live preview
For Next.js revalidatePath / revalidateTag, the webhook hits a signed endpoint on your frontend that calls res.revalidate(). The CMS sends a JSON body with the changed slug; your endpoint verifies a shared secret and purges just that path. Fast, surgical, no full rebuild needed.
Step 8: Plugins and Extensions
Strapi plugin marketplace (market.strapi.io) has ~150+ plugins. The ones worth installing on day one:
- GraphQL — official, adds a GraphQL playground.
- SEO — validates meta tags, OG images, titles per entry.
- Sitemap — generates
sitemap.xmlfrom your content. - Documentation — auto-generates OpenAPI docs for your REST endpoints.
- i18n — already built in as of v5, just enable it.
Directus extensions are a different model — you write them in TypeScript as interfaces, displays, layouts, modules, hooks, endpoints, or operations (for Flows). Check the awesome-directus list. Common additions:
- Custom field interfaces (e.g., a color picker with brand-palette constraints).
- Custom Flows operations (Slack notify, Stripe sync, custom webhook transforms).
- The Visual Editor extension for in-context editing on your live site.
Step 9: Migrating Content Between Environments
Once you have dev / staging / production, the question becomes: how do schema and content flow between them without clobbering each other?
Strapi
The Content Transfer feature (built into v5) does both schema and data. Create a transfer token on the destination, then on the source run:
strapi transfer --to https://staging.cms.example.com/admin
--to-token $TRANSFER_TOKEN
Include --only content or --only config as needed. For schema changes, the standard flow is: develop locally → commit the src/api schema files → deploy to staging → Strapi auto-migrates the Postgres schema on boot.
Directus
Use the schema snapshot pattern:
# Export schema from source environment
docker compose exec directus npx directus schema snapshot ./snapshot.yaml
# Copy to destination host, then apply
docker compose exec directus npx directus schema apply ./snapshot.yaml
For content, Directus does not ship a content-migration command — the idiomatic move is to use pg_dump/pg_restore on the managed Postgres (which DanubeData exposes) and filter the tables you care about. For small sites this is clean; for large content sets, build a one-off script that reads from source API and writes to destination API.
Step 10: Backups
With managed Postgres + S3, your backup surface is small and mostly automatic.
- Postgres: DanubeData managed Postgres does daily snapshots automatically. Add an application-level
pg_dumpto S3 for extra safety:
#!/bin/bash
# /opt/backup.sh - run daily via cron
DATE=$(date +%Y-%m-%d)
PGPASSWORD=$DB_PASSWORD pg_dump -h your-managed-pg-host.danubedata.ro
-U strapi -d strapi | gzip > /tmp/strapi_$DATE.sql.gz
rclone copy /tmp/strapi_$DATE.sql.gz danubedata:your-backup-bucket/pg/
rm /tmp/strapi_$DATE.sql.gz
find /tmp -name "strapi_*.sql.gz" -mtime +7 -delete
- Media: already on S3 with versioning. No separate backup needed if versioning is enabled and you have a lifecycle rule to expire old versions after, say, 90 days.
- Config: your
docker-compose.yml,.env(encrypted!), and Strapisrc/directory should live in a git repository. Treat the VPS as disposable.
Add the cron job:
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/backup.sh >> /var/log/backup.log 2>&1") | crontab -
Operational Notes After Launch
- Monitor the VPS with Prometheus node_exporter or a one-click Uptime Kuma container. CMS processes occasionally leak memory on hot-reload cycles; a weekly restart is fine.
- Lock down the admin. Both tools support IP allowlisting at the Caddy/nginx layer. If your team is small and has fixed office IPs, adding a
@allowedmatcher to the Caddyfile is a two-line, high-value security win. - Rotate secrets on a schedule. API tokens especially — if one leaks into a git repo, rotate immediately.
- Keep up with releases. Both projects have an active release cadence (monthly minor versions, quarterly significant features). Pin your Docker image tag to a minor version (
directus:11, notdirectus:latest) so upgrades are deliberate. - Staging is worth it. Spin up a second DD Micro (€7.49/mo) for a staging copy. Schema changes, plugin upgrades, and content migrations should always land in staging first.
Cost Summary
| Scenario | Contentful / Sanity | DanubeData Self-Hosted | Annual Savings |
|---|---|---|---|
| Solo blog / side project | $99/mo | ~€12/mo (Micro + S3) | ~$1,040/yr |
| Small agency / marketing site | $300/mo | ~€36/mo (DD Small + PG + S3) | ~$3,170/yr |
| Mid-sized editorial site | $489/mo | ~€36/mo | ~$5,430/yr |
| Multi-brand (5+ sites) | $1,500+/mo | ~€60/mo (DD Medium + PG + S3) | ~$17,280/yr |
FAQ
Should I pick Strapi or Directus?
If you are building a new editorial site with a modern frontend (Next.js, Nuxt, Astro), pick Strapi. If you are wrapping an existing database, building an internal tool, or need per-field/per-row permissions, pick Directus. Both are excellent in their lane; the wrong tool in the wrong scenario is what causes regret.
Is GraphQL supported in both?
Yes. Directus ships GraphQL built-in — just hit /graphql. Strapi requires enabling the official GraphQL plugin (npm install @strapi/plugin-graphql), after which /graphql is live with a playground. Both tools expose the same schema over REST and GraphQL, so you can mix-and-match at the frontend.
How well do these scale?
For read-heavy workloads (the typical "content on a marketing site" scenario), both scale horizontally behind a load balancer — the process is stateless and all state lives in Postgres + S3. A single DD Small comfortably handles several hundred requests per second once you cache the API responses at the frontend or CDN layer. For extreme write-heavy scenarios (high-frequency IoT ingest, for example), these are not the right tool; use a dedicated service and point a CMS at its database in read-only mode.
Can I migrate from WordPress?
Yes, though it requires effort proportional to your plugin count. The clean path: export WordPress posts as JSON (via the WP REST API or a tool like WP2Static), transform the shape to match your new Strapi/Directus content type, and POST to the new CMS's API. For media, use wget -r against the /wp-content/uploads directory and upload to S3. Rewrite media URLs in the imported content during the transform step. Expect one engineer-week for a typical content-heavy WordPress blog.
Can I migrate from Contentful or Sanity?
Yes — both have export commands that produce JSON dumps of all content. Map their content models to your new collections (an Article in Contentful becomes an article in Strapi with matching fields), run a transform script, and import. Because you are moving from one JSON API to another, the mechanics are simpler than a WordPress migration. The hard part is replicating any custom Contentful "apps" or Sanity Studio customizations — those are proprietary and need rewriting as Strapi plugins or Directus extensions.
Do I need managed Postgres, or can I run Postgres in Docker?
You can run Postgres in Docker on the same VPS. You shouldn't in production. When your VPS disk fills up, your CMS dies and so does your database. Managed Postgres on DanubeData costs €19.99/month and buys you automated backups, snapshot restore, and isolation of failure domains. For a staging environment or a hobby project, in-container Postgres is fine.
How do I preview draft content?
Strapi has built-in draft/publish. Your frontend calls the API with status=draft when in preview mode (authenticated with a preview token). Directus uses a status field convention — create a status enum (draft, published, archived) on each collection and filter in your frontend. Next.js draftMode and Nuxt useState make the preview-vs-live toggle trivial.
What about i18n?
Strapi's i18n plugin is polished — each entry gets localized variants, the admin UI has a language picker, and the API returns the locale you request via ?locale=de. Directus uses the "translations" collection pattern: for each translatable collection, you add a *_translations junction table keyed by language. More flexible, slightly more setup. For >5 languages with complex fallback rules, Directus wins; for standard EN/DE/FR/IT, Strapi is faster to set up.
Get Started
The shortest path from zero to production headless CMS in 2026:
- Sign up at danubedata.ro. Grab the €50 signup credit.
- Spin up a DD Small VPS (€12.49/mo), a managed Postgres (€19.99/mo), and an S3 bucket (€3.99/mo).
- Follow step 2A (Strapi) or 2B (Directus) above. Plan ~45 minutes.
- Model your content, wire a webhook to your frontend host, and you are live.
Your total runway on €50 of credit: roughly six weeks of full production setup, or 12+ weeks of a Micro-tier evaluation. More than enough time to decide whether self-hosting beats SaaS for your team (spoiler: at anything beyond a solo project, it always does).
👉 Start your DanubeData account — VPS from €4.49/mo, managed Postgres from €19.99/mo, all hosted in Falkenstein, Germany with full GDPR compliance.
Need a hand with the migration? Get in touch — we have migrated teams off Contentful, Sanity, and WordPress, and we are happy to help you scope yours.