BlogTutorialsSelf-Host Strapi or Directus: Open-Source Headless CMS Setup Guide (2026)

Self-Host Strapi or Directus: Open-Source Headless CMS Setup Guide (2026)

Adrian Silaghi
Adrian Silaghi
April 20, 2026
14 min read
15 views
#strapi #directus #headless-cms #self-hosted #vps #nodejs #api
Self-Host Strapi or Directus: Open-Source Headless CMS Setup Guide (2026)

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 toolDirectus.
  • 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

  1. 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.
  2. 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.
  3. 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.
  4. 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), slug
  • category — name, slug, description
  • tag — name, slug
  • article — 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=80 endpoint 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.xml from 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_dump to 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 Strapi src/ 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 @allowed matcher 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, not directus: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:

  1. Sign up at danubedata.ro. Grab the €50 signup credit.
  2. Spin up a DD Small VPS (€12.49/mo), a managed Postgres (€19.99/mo), and an S3 bucket (€3.99/mo).
  3. Follow step 2A (Strapi) or 2B (Directus) above. Plan ~45 minutes.
  4. 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.

Share this article

Ready to Get Started?

Deploy your infrastructure in minutes with DanubeData's managed services.