How I Self-Host My Ghost Blog with Caddy in Docker

How I Self-Host My Ghost Blog with Caddy in Docker
Photo by Ian Taylor / Unsplash

A walkthrough with real errors (and fixes)

So you want a blazing-fast, auto-HTTPS blog on your own domain, powered by Ghost and reverse-proxied by Caddy — all in Docker?

Well, you just did it. Here’s exactly how, including every bump and how to smooth it.


Step 1: Start with the Basics — Compose + Caddyfile

You write a docker-compose.yml with:

  • A ghost service (the blogging engine)
  • A mysql service (the database)
  • A caddy service (for reverse proxy + HTTPS)
services:
  ghost:
    image: ghost:5-alpine  # Lightweight official Ghost image
    container_name: ghost
    restart: unless-stopped  # Auto-restart on crash or reboot
    depends_on:
      - mysql  # Ensure MySQL starts before Ghost
    environment:
      url: https://yourdomain.com  # CHANGE THIS to your domain *with https*!
      database__client: mysql
      database__connection__host: mysql
      database__connection__user: ghost  # Matches MYSQL_USER below
      database__connection__password: secret123  # Matches MYSQL_PASSWORD
      database__connection__database: ghostdb  # Matches MYSQL_DATABASE
    volumes:
      - ghost_data:/var/lib/ghost/content  # Persist blog content
    expose:
      - 2368  # Internal port used by Caddy (not published to host)

  mysql:
    image: mysql:8
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: rootpass  # Only used for root MySQL access (optional)
      MYSQL_DATABASE: ghostdb
      MYSQL_USER: ghost
      MYSQL_PASSWORD: secret123
    volumes:
      - mysql_data:/var/lib/mysql  # Persist database

  caddy:
    image: caddy:alpine  # Lightweight Caddy with auto HTTPS
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"    # HTTP → used for automatic redirect to HTTPS and cert validation
      - "443:443"  # HTTPS requires this port
    volumes:
      - caddy_data:/data    # TLS certs and internal state
      - caddy_config:/config
      - ./Caddyfile:/etc/caddy/Caddyfile  # Your custom config

volumes:
  ghost_data:
  mysql_data:
  caddy_data:
  caddy_config:

nano docker-compose.yml

You pair it with a Caddyfile:

www.everyhub.org {
  redirect https://everyhub.org{uri}
}

everyhub.org {
  reverse_proxy ghost:2368
}

nano Caddyfile

Smart. Minimal. Effective.

Set your domain's (https://everyhub.org) DNS rules so that the custom domain points to the server's public IP address!


Problem 1: Disk Full

When I first ran:

docker compose up -d

I hit this:

no space left on device

Fix:

I went full sysadmin mode:

  • I pruned Docker (docker system prune -a)
  • Deleted dangling volumes
  • Ran df -h, found /home/ubuntu at 7.5GB
  • Used du to locate ft_userdata eating 5.1GB (from a previous project), e.g. du -sh /home/* | sort -hr and du -h --max-depth=1 /home/ubuntu | sort -hr.

Boom — free space reclaimed. Containers could now pull and extract successfully.


Problem 2: Caddy Port Conflict

My Docker Caddy couldn’t start:

failed to bind host port for 0.0.0.0:80: address already in use

Fix:

I ran:

sudo lsof -i :80

Discovered: host-level Caddy was already running (via systemd)

So I:

sudo systemctl stop caddy
sudo systemctl disable caddy

Now Docker Caddy could take over port 80 and 443.


Problem 3: Ghost Container Crashing

You saw this in docker compose logs -f ghost:

connect ECONNREFUSED 127.0.0.1:3306

Classic Ghost + MySQL misconfig.

Fix:

I correctly added a MySQL service in docker-compose.yml, then:

  • Set database__connection__host: mysql
  • Made sure credentials in Ghost and MySQL matched
  • Added missing volumes: section at the bottom of the docker-compose.yml file

Problem 4: 502 from Caddy

I finally saw:

curl -I https://everyhub.org
HTTP/2 502

But Ghost and MySQL were both running! Why?

Insight:

From inside the Caddy container,

I ran:

curl -I http://ghost:2368

For this I had to install curl first and then run in the container

docker exec -it caddy apk add curl
docker exec -it caddy curl -I http://ghost:2368

run in the Docker container caddy for debugging

And saw a 301 redirect to HTTPS.

Ghost was internally redirecting to https://ghost:2368 — a nonexistent endpoint, since TLS termination is Caddy’s job.

Fix:

I updated Ghost’s url to:

url: http://everyhub.org

And restarted the stack by:

docker compose down -v
docker compose up -d

# or just restart Caddy:
docker compose restart caddy

Result: It Works!

I confirmed:

curl -I https://everyhub.org
# → HTTP/2 200 OK 

The site is live, secure, and ready for content.


Lessons Learned

  1. Disk space is real — clean often (at least in the server I am in, where I have only in total 20 GB)
  2. Only one service can own ports 80/443 — Docker or system's Caddy or Nginx, never both.
  3. Ghost needs MySQL configured exactly right, or it crashes silently.
  4. Ghost’s internal HTTPS redirect can kill your reverse proxy unless you explicitly disable it via the url.

(Ghost's cleanliness remembers of that of Medium's!)

Happy blogging with Ghost and Caddy!