Back to blog
Blog

Run Directus on a VPS as a Self-Hosted Headless CMS

Run Directus on a VPS with Docker Compose, Postgres, HTTPS, backups, and safe upgrades for a self-hosted headless CMS stack.

By Anurag Singh
Updated on Nov 06, 2026
Category: Blog
Share article
Run Directus on a VPS as a Self-Hosted Headless CMS

Hosted headless CMS platforms feel effortless—until pricing jumps, a key feature moves to a higher tier, or compliance asks where the data lives. If your content feeds websites, mobile apps, and internal tools, run Directus on a VPS. You get a boring (in the best way) stack: one database, one API, and infrastructure you control.

Directus is an open-source headless CMS that sits on top of a SQL database. It exposes content through REST and GraphQL. You model collections, set roles and permissions, and give editors a clean admin UI. Your developers still keep full ownership of the underlying data model.

Why Directus is a practical headless CMS for self-hosting

Directus is a good fit if you already think in tables and relationships. It also works well if you want a CMS that doesn’t “own” your data.

Your content stays in PostgreSQL (or another supported SQL database). Directus adds the admin app and API layer on top.

  • Teams shipping across channels: one backend for a marketing site, a docs portal, and a mobile app.
  • Agencies: a repeatable CMS pattern per client, with per-collection permissions.
  • Ops-minded teams: a containerized service + database + object storage, with backups you can actually test.

License note: Directus is open source (AGPLv3). If you modify Directus and offer it as a network service to users outside your organization, AGPL obligations can apply.

If you deploy an unmodified build for your own business, it’s usually straightforward. Still, have legal confirm your distribution model.

Hosted headless CMS vs Directus: what you gain (and what you own)

Directus often replaces SaaS headless CMS tools that bundle hosting, API access, and the editorial UI. The trade-off is simple. You gain control and a flatter cost curve, but you take responsibility for uptime, upgrades, and patching.

AreaHosted headless CMSDirectus self-hosted
Data locationVendor-controlled region optionsYour VPS / dedicated server region, your policies
Cost curveScales with seats, API calls, bandwidthMostly infrastructure cost (CPU/RAM/disk)
CustomizationLimited to vendor constraintsFull control: reverse proxy, auth, network rules
Ops workloadMinimalYou handle upgrades, backups, monitoring

If you already run a VPS for your app, adding Directus is often the quickest path to bring content in-house. It also avoids building (and maintaining) a custom admin panel.

What you need before you run Directus on a VPS

Directus runs well on modest hardware. The areas that deserve extra attention are the database and your file storage plan.

Here’s a sensible production baseline for 2026.

  • OS: Ubuntu 24.04 LTS or Debian 12.
  • CPU/RAM: 2 vCPU / 4 GB RAM for small teams; 4 vCPU / 8 GB RAM if you expect frequent imports, many extensions, or heavy API usage.
  • Disk: NVMe recommended. Budget for Postgres data + uploads + backups (often 3–5x database size).
  • Ports: 80/443 to the reverse proxy only. Keep Postgres private on a Docker network.

If you want consistent performance and full control, a VPS is a strong starting point. HostMyCode’s HostMyCode VPS plans work well for Directus + Postgres. You can also increase resources without redesigning your deployment.

Adopt one habit early: treat Directus as stateless. Treat Postgres + uploads as state. This makes backups, restores, and migrations easier to reason about.

Deployment overview: Docker Compose + PostgreSQL + reverse proxy

Directus publishes official container images. That makes Docker Compose the simplest deployment path.

A typical setup includes:

  • directus: the app/API container
  • postgres: primary database
  • reverse proxy: Nginx or Caddy terminating TLS and routing traffic

You can run the reverse proxy on the host (system packages) or as a container. If you already standardize on Caddy for Docker routing, HostMyCode has a guide you can follow as-is: set up Caddy reverse proxy for Docker.

Docker Compose example for Directus with Postgres

This Compose file keeps the database off the public network. It also persists state to named volumes.

Create a directory such as /opt/directus. Then add docker-compose.yml and an .env file.

# /opt/directus/docker-compose.yml
services:
  postgres:
    image: postgres:16
    container_name: directus-postgres
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - internal
    restart: unless-stopped

  directus:
    image: directus/directus:latest
    container_name: directus-app
    depends_on:
      - postgres
    environment:
      KEY: ${DIRECTUS_KEY}
      SECRET: ${DIRECTUS_SECRET}

      DB_CLIENT: pg
      DB_HOST: postgres
      DB_PORT: 5432
      DB_DATABASE: ${POSTGRES_DB}
      DB_USER: ${POSTGRES_USER}
      DB_PASSWORD: ${POSTGRES_PASSWORD}

      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}

      PUBLIC_URL: https://${DOMAIN}

      # Store uploaded files locally in a volume (simple + reliable)
      STORAGE_LOCATIONS: local
      STORAGE_LOCAL_DRIVER: local
      STORAGE_LOCAL_ROOT: /directus/uploads

      # Optional: reduce surprises behind a proxy
      TRUST_PROXY: "true"
    volumes:
      - uploads:/directus/uploads
      - extensions:/directus/extensions
    networks:
      - internal
      - web
    restart: unless-stopped

networks:
  internal:
    internal: true
  web:
    external: true

volumes:
  pgdata:
  uploads:
  extensions:

And a matching environment file:

# /opt/directus/.env
DOMAIN=cms.example.com

POSTGRES_DB=directus
POSTGRES_USER=directus
POSTGRES_PASSWORD=CHANGE_ME_LONG_RANDOM

DIRECTUS_KEY=CHANGE_ME_32+_CHARS_RANDOM
DIRECTUS_SECRET=CHANGE_ME_32+_CHARS_RANDOM

ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=CHANGE_ME_LONG_RANDOM

Two details that prevent late-night outages:

  • Keep internal: true on the private network so Postgres never binds to the host.
  • Pin Postgres to a major version you plan to keep for a while (16 is a common baseline). Don’t float it to latest in production.

Bring it up:

cd /opt/directus
docker compose up -d
docker compose ps

HTTPS reverse proxy: two clean options (Nginx or Caddy)

Put Directus behind TLS. You’ll also want correct proxy headers so sessions and redirects behave properly.

Set upload limits at the proxy. Otherwise, large files can fail in confusing ways.

Option A: Caddy with automatic Let’s Encrypt

If you want minimal configuration and automatic certificates, Caddy is a strong choice. You can run it on the host or in Docker.

A common setup is host-level Caddy proxying to the Directus container over the Docker network.

# /etc/caddy/Caddyfile
cms.example.com {
  encode zstd gzip

  reverse_proxy directus-app:8055 {
    header_up X-Forwarded-Proto {scheme}
    header_up X-Forwarded-Host {host}
    header_up X-Forwarded-For {remote}
  }

  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    X-Content-Type-Options "nosniff"
    X-Frame-Options "SAMEORIGIN"
    Referrer-Policy "strict-origin-when-cross-origin"
  }
}

If you haven’t set up Caddy on your VPS yet, HostMyCode’s guide covers the full Let’s Encrypt flow and the usual snags: set up Let’s Encrypt with Caddy.

Option B: Nginx (fine-grained control)

Nginx is a better fit if you host multiple sites on one box. It’s also a good choice if you need advanced caching rules or explicit request-size limits.

# /etc/nginx/sites-available/directus.conf
server {
  listen 80;
  server_name cms.example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name cms.example.com;

  # Cert paths depend on your ACME client
  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_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;
  }
}

If uploads fail with a 413 error, the proxy limit is usually the culprit. This shows the fix pattern (with examples): Nginx 413 Request Entity Too Large fix.

Backups that actually restore: database + uploads + config

With Directus, your critical state is:

  • PostgreSQL (schema + content + permissions + settings)
  • Uploads directory (if you store files locally)
  • Extensions (custom endpoints, hooks, interfaces)
  • Environment/config (.env, reverse-proxy config)

A backup you can’t restore under pressure doesn’t count. Do nightly database dumps plus file snapshots. Then run a restore test at least monthly.

Postgres dump (safe default)

You can dump from the host by exec’ing into the database container. This example writes to /var/backups/directus on the VPS.

sudo mkdir -p /var/backups/directus
sudo chmod 700 /var/backups/directus

# Create a compressed dump
docker exec -t directus-postgres \
  pg_dump -U directus directus | gzip > /var/backups/directus/directus-$(date +%F).sql.gz

Want a cleaner workflow that includes restore testing? The same approach applies in this HostMyCode guide (the example is generic Postgres, but the steps match): set up PostgreSQL backups with pg_dump + restore test.

Uploads/extensions backup

If you use Docker volumes, back them up as files by copying data out. A straightforward option is a tar archive of the mapped directories/volumes.

# Example: back up uploads and extensions volumes
docker run --rm \
  -v directus_uploads:/data/uploads \
  -v directus_extensions:/data/extensions \
  -v /var/backups/directus:/backup \
  alpine sh -c 'tar -czf /backup/files-$(date +%F).tar.gz -C /data uploads extensions'

Offsite matters. Keep at least one copy outside the VPS—object storage, another server, or a backup provider. If the disk fails, local backups disappear with it.

Updates without drama: a repeatable upgrade workflow

Directus upgrades are usually smooth. Still, assume a schema migration may run on first startup.

Stick to a routine that’s deliberately boring:

  1. Read release notes for breaking changes (especially around auth, storage, and extensions).
  2. Take backups (DB + uploads + extensions). Don’t skip this.
  3. Pull images and restart containers.
  4. Verify health (admin UI login, one API call, one file upload).
cd /opt/directus

# 1) backup first (scripts recommended)
# 2) pull and restart
docker compose pull
docker compose up -d

# 3) watch logs for migrations/errors
docker compose logs -f --tail=200 directus

Pin Directus for production. Once the deployment is stable, replace directus/directus:latest with a specific tag in your Compose file. This prevents surprise major upgrades during routine maintenance.

Operational notes you’ll care about after week two

Most production problems blamed on Directus come from the surrounding infrastructure. Common causes are timeouts, missing headers, disk pressure, or a DNS slip during a cutover.

  • Disk growth: Postgres + uploads creep upward. Alert on filesystem usage well before 90%.
  • Reverse proxy timeouts: long imports/exports may require higher proxy timeouts.
  • Correct PUBLIC_URL: if it’s wrong, redirects and cookies get weird fast.
  • DNS cutovers: lower TTL before moving the hostname to a new VPS to avoid sticky clients.

If you’re moving an existing domain or switching DNS providers during rollout, follow a no-downtime sequence. This HostMyCode checklist is solid: migrate a domain to Cloudflare DNS without downtime.

Where Directus fits in your hosting stack (and when to scale up)

For many teams, Directus starts as “one more service” on the same VPS as the app. That’s fine until you hit a few predictable pressure points:

  • Multiple environments (dev/staging/prod) with real data volumes
  • High API concurrency from mobile clients or public sites
  • Large assets (media-heavy catalogs) where disk IO becomes the bottleneck

From there, you usually split responsibilities. Keep Directus + the app tier on one VPS. Move Postgres to a dedicated database host. Or step up to a dedicated server for steadier CPU and disk throughput.

If you’re approaching that stage, HostMyCode’s managed VPS hosting is a practical option. It helps with OS patching, monitoring, and backups while you keep control of the application layer.

Set up monitoring early, even if it’s lightweight. Basic visibility into CPU, memory, disk, and container health prevents slow-burn failures that later feel “random.”

If you want a proven dashboard stack, the approach in Deploy Grafana on a VPS with Docker, TLS, and backups maps well to a Directus host.

If you want a stable place to host Directus with predictable performance, start with a HostMyCode VPS and scale as your API traffic grows. If you’d rather not babysit OS updates and backup plumbing, Managed VPS hosting gives you a practical middle ground for production workloads.

FAQ

Is Directus a good replacement for a hosted headless CMS?

Yes—if your priority is owning the database and keeping costs predictable. You give up some SaaS convenience in exchange for being responsible for uptime, upgrades, and security patching.

Should I store uploads on the VPS disk or object storage?

VPS disk is the simplest option and works well for small to mid-size sites. If you expect a large media library, object storage scales more easily and keeps disk pressure off the VPS.

Do I need a separate server for PostgreSQL?

Not at the start. Running Postgres on the same VPS keeps the setup simple, and you can split it out later for independent scaling or stronger isolation.

What’s the minimum safe backup set for Directus?

Database dump + uploads + extensions + a copy of your .env and reverse proxy config. Run a restore test on a staging VPS before you rely on it.

Summary: a headless CMS you can operate like any other service

Directus works best when you run it like a normal production service. Use a containerized API layer backed by Postgres. Keep versioned config, private networking, and TLS at the edge. Rehearse your backups.

That’s the gap between “self-hosted” and “production-ready.”

If you’re ready to run Directus on a VPS for a client project or your own platform, start with a right-sized HostMyCode VPS. Then add monitoring and offsite backups as usage grows. Keep it simple, keep it testable, and you’ll sleep better.

Run Directus on a VPS as a Self-Hosted Headless CMS | HostMyCode