How to Run a CMS in Docker: Self-Hosted Setup in 15 Minutes

July 24, 2026 · 8 min read
How to Run a CMS in Docker: Self-Hosted Setup in 15 Minutes

Docker turned self-hosting from "follow a 40-step wiki page" into "run one file." For a CMS, that's the difference between an afternoon of PHP extension debugging and a working admin panel before your coffee cools. But containers also add a layer you have to understand when something breaks — and for some CMS stacks, they're pure overhead.

TL;DR: A PHP-based CMS runs in Docker with a 40-line docker-compose.yml: one app container (php-fpm), one web server (nginx), one database (MySQL), three named volumes. Copy the file below, run docker compose up -d, and you have a production-shaped stack in about 15 minutes. Whether you should is a real question: Docker earns its keep when you standardize many services on one VPS. For a single PHP CMS, plain shared hosting is simpler and cheaper — that's the honest trade-off this guide covers.


When Docker Makes Sense for a CMS (and When It Doesn't)

Run your CMS in Docker when:

  • The VPS already runs other containerized services and you want one deployment pattern for everything
  • You need identical dev/staging/production environments — compose files kill "works on my machine"
  • The CMS has a hairy dependency chain (specific Node versions, image-processing libraries) you'd rather pin than install
  • You rebuild servers often and want infrastructure that's a file, not a snowflake

Skip Docker when:

  • You're running one PHP CMS and nothing else — cPanel hosting or a plain VPS with PHP-FPM is less to operate. The $5 VPS setup guide covers that path without containers.
  • Your host is shared hosting — no Docker there anyway
  • The team doesn't know containers — a mystery layer under production is worse than a boring stack you understand

Node-based CMSs (Strapi, Payload, Ghost) benefit from Docker more than PHP ones: pinning the Node runtime and process management is exactly the pain containers solve. We covered that ops surface in Strapi's deployment pain.


The 15-Minute Setup

You need a VPS with Docker Engine and the compose plugin installed (curl -fsSL https://get.docker.com | sh on a fresh Ubuntu box), a domain pointed at it, and a CMS codebase. The example uses a Laravel-based CMS layout — adjust paths for yours.

Step 1 — Project structure

cms-docker/
├── docker-compose.yml
├── .env
├── nginx/
│   └── default.conf
└── src/            # your CMS code lives here

Step 2 — The compose file

services:
  app:
    image: php:8.3-fpm
    volumes:
      - ./src:/var/www/html
    environment:
      - DB_HOST=db
      - DB_DATABASE=${DB_DATABASE}
      - DB_USERNAME=${DB_USERNAME}
      - DB_PASSWORD=${DB_PASSWORD}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  web:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
    volumes:
      - ./src:/var/www/html:ro
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app
    restart: unless-stopped

  db:
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=${DB_DATABASE}
      - MYSQL_USER=${DB_USERNAME}
      - MYSQL_PASSWORD=${DB_PASSWORD}
      - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      retries: 10
    restart: unless-stopped

volumes:
  dbdata:

Secrets live in .env next to the compose file — never hardcode them in YAML:

DB_DATABASE=cms
DB_USERNAME=cms
DB_PASSWORD=change-this-long-random-string
DB_ROOT_PASSWORD=change-this-too

Step 3 — Minimal nginx config

server {
    listen 80;
    root /var/www/html/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

The one line people miss: fastcgi_pass app:9000; — inside a compose network, containers reach each other by service name, not localhost.

Step 4 — Up

docker compose up -d
docker compose ps          # all three should be "running"
docker compose exec app php artisan migrate   # or your CMS's installer

Fifteen minutes, most of it image download time.


The Parts the Quickstart Skips

The compose file above is honest but minimal. Four things separate it from production-grade:

  1. TLS. Put a reverse proxy in front — Caddy gets you automatic Let's Encrypt certificates with a 3-line config, or use Cloudflare in front of port 80. Don't serve a login page over plain HTTP.
  2. Backups. A named volume is not a backup. Cron a mysqldump out of the db container and ship it off the server: docker compose exec -T db mysqldump -u root -p"$DB_ROOT_PASSWORD" cms | gzip > backup.sql.gz. The full rotation setup is in our backup strategy guide.
  3. Updates. Containers don't patch themselves. docker compose pull && docker compose up -d monthly, and subscribe to your images' security feeds. An outdated container is exactly as vulnerable as an outdated server — the layer doesn't absolve you, as we covered in why platform choice matters for security.
  4. Resource limits. MySQL will happily eat a 2 GB VPS. Add mem_limit: 512m to the db service on small boxes, or use SQLite for small sites and delete the db service entirely.

The Five Failures Everyone Hits First

Every first Docker CMS deploy breaks in one of the same five ways. Check these before opening an issue anywhere:

  1. 502 Bad Gateway from nginx. The web container can't reach php-fpm. Almost always a wrong fastcgi_pass — it must be the compose service name (app:9000), and both services must be in the same compose file (same default network). docker compose logs web shows the refused connection.

  2. "Connection refused" from the CMS to MySQL. The app booted faster than the database. The healthcheck + condition: service_healthy in the compose file above prevents this — if you removed it, that's why. MySQL 8 takes 20–40 seconds to initialize on first run.

  3. File permission errors on storage/ or uploads. The php-fpm container runs as www-data (UID 33); your host files are probably owned by your login user. Fix inside the container: docker compose exec app chown -R www-data:www-data /var/www/html/storage.

  4. Missing PHP extensions. The stock php:8.3-fpm image ships without pdo_mysql, gd, and friends. For anything beyond a hello-world you'll graduate to a small Dockerfile: FROM php:8.3-fpm + RUN docker-php-ext-install pdo_mysql gd — then build with docker compose build.

  5. Everything gone after docker compose down -v. The -v flag deletes named volumes — including dbdata, which is your database. Never use -v on production. Plain down keeps volumes; up -d reattaches them.

The pattern behind all five: containers fail loudly in logs and silently in browsers. docker compose logs -f <service> is the first command to reach for, every time.


Does UnfoldCMS Need Docker?

No — and that's deliberate. UnfoldCMS is PHP 8.3 on Laravel, so it runs on $5/month shared cPanel hosting with no containers, no process manager, and no build step on the server. For most single-site deployments that's the simpler path, and it's why we build for it.

But "doesn't need" isn't "can't use." If your infrastructure standardizes on containers, the compose pattern above is exactly how you'd run it: php-fpm app container, nginx, MySQL or SQLite, one volume for storage/. The stack is boring on purpose — boring deploys are the feature.

The wider decision — shared hosting vs VPS vs containers — is mapped in shared hosting vs VPS for a CMS and the self-hosted CMS guide.


FAQ

How much RAM does a Dockerized CMS need?

For the PHP stack above: 1 GB works, 2 GB is comfortable. MySQL is the hungry one — with mem_limit set or SQLite swapped in, the whole stack fits a $5–7/month VPS. Node-based CMS containers typically want 2 GB as a floor.

Is Docker safer than installing the CMS directly?

Marginally, by isolation — a compromised app container is one layer away from the host. But unpatched images cancel that advantage, and most real CMS breaches come through the application (plugins, weak admin passwords), which containers don't prevent. Treat Docker as an ops tool, not a security control.

Can I run the database outside Docker?

Yes, and on small VPSes it's common: run MySQL natively (or use a managed database), point DB_HOST at it, and delete the db service. You lose one-file portability but gain simpler backups and memory behavior.

Docker vs docker compose vs Kubernetes for a CMS?

Compose is the right ceiling for a CMS. It's one file, runs on one VPS, restarts on reboot with restart: unless-stopped. Kubernetes buys you nothing for a content site except a second job. Plain docker run commands work but you'll wish you'd written the compose file by the third flag.

How do I update the CMS itself inside Docker?

The code lives in the ./src bind mount, so update it like any deployment: pull the new release into src/, then docker compose exec app php artisan migrate (or your CMS's update command). The containers only change when you bump image tags.


Methodology

Commands and compose syntax were written for Docker Engine 27 / Compose v2 and PHP 8.3 / MySQL 8.0 / nginx 1.27 images as published on Docker Hub in July 2026 — pin your own tags before relying on them. VPS pricing references Hetzner and DigitalOcean published rates. We build UnfoldCMS, which is designed to not require Docker; this guide exists because plenty of teams containerize everything anyway, and that's a legitimate way to run it.

Free & Open Source

Own your CMS. No subscriptions.

Unfold CMS is free to download and self-host. Built on Laravel + React, full source code included.

Share this post:

Discussion

Comments (0)

Leave a Comment

Please log in to leave a comment.

Don't have an account? Register here

No comments yet. Be the first to share your thoughts!

Keep Reading

Related Posts

Back to all posts