How I Self-Host My Ghost Blog with Caddy in Docker
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 locateft_userdata
eating 5.1GB (from a previous project), e.g.du -sh /home/* | sort -hr
anddu -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
- Disk space is real — clean often (at least in the server I am in, where I have only in total 20 GB)
- Only one service can own ports 80/443 — Docker or system's Caddy or Nginx, never both.
- Ghost needs MySQL configured exactly right, or it crashes silently.
- 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!