In this tutorial, you will set up a self-hosted Ghost blog using Docker Compose, running Ghost alongside its own MySQL database as two containers defined in one docker-compose.yml. You'll set Ghost's url variable correctly before first startup, put it behind an Nginx reverse proxy with a free TLS certificate while keeping the container's port bound to localhost only, complete the first-run setup wizard, and verify that content persists across container restarts.
Prerequisites
A server with a non-root sudo user and SSH access
Docker Engine and the Docker Compose plugin already installed (docker compose version to confirm)
A registered domain or subdomain with its DNS A record pointed at the server's public IP, if serving this on the public internet
Nginx installed and Certbot available for TLS, if going beyond local testing (see the earlier reverse-proxy tutorial in this series)
Let an AI agent do this for you
Copy a ready-made prompt for an AI coding assistant with terminal access to your server (Claude Code, Cursor, or similar) — it can carry out the steps below for you. Review what it plans to run before it executes anything.
Ghost is an open-source publishing platform focused on blogging and newsletters — a lighter, more opinionated alternative to a general-purpose CMS like WordPress. The Ghost project maintains an official Docker image and documents a standard self-hosting pattern for it: Ghost runs in one container and talks to a MySQL database in a second container, both defined together in a single docker-compose.yml. This tutorial walks through that exact pattern — from an empty project directory to a TLS-terminated blog with verified, persistent content.
This tutorial assumes Docker Engine and the Docker Compose plugin are already installed. If you plan to serve the blog on a real public domain, it also assumes Nginx and Certbot are already set up on the server (see the earlier reverse-proxy tutorial in this series) and that you have a domain — for example blog.example.com — with a DNS A record pointed at your server's public IP. If you're only testing locally, you can skip the reverse-proxy step and reach Ghost directly on port 2368.
Step 1 — Create a Project Directory
Docker Compose projects are easiest to manage from a dedicated directory that holds just the compose file and nothing else. Create one under /opt, which is the conventional location for self-installed application data on Linux:
bash
sudo mkdir -p /opt/ghost
cd /opt/ghost
Step 2 — Define the Docker Compose Configuration
Create docker-compose.yml in that directory with two services: db (MySQL 8) and ghost (Ghost 5 on Alpine, a small base image). Each has a named volume so its data survives container recreation — db_data for MySQL's data directory, ghost_data for Ghost's content directory (themes, images, and its own SQLite-free config).
Two details are worth noting. First, image tags are pinned to major versions (mysql:8, ghost:5-alpine) rather than left as latest — this avoids an unattended minor/major upgrade breaking Ghost's database schema on a routine docker compose pull. Second, the db service deliberately has no ports: entry at all. MySQL only needs to be reachable from the ghost container over Docker's internal network (they can resolve each other by service name — that's why database__connection__host=db works), and it should never be exposed to the host or the internet.
Tricknowtech VPS Hosting
Dedicated KVM resources and full root access — deployed in under 60 seconds, no ticket required.
“Replace a_strong_root_password with a real, unique, high-entropy password before running this anywhere but a throwaway local test. Never commit a docker-compose.yml containing a real password to a public repository. The better practice is to put secrets in a separate .env file in the same directory (Docker Compose substitutes ${VAR} references from it automatically) and reference it as MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} in the compose file instead of a literal value. Whichever file ends up holding the real password, lock it down: sudo chmod 600 docker-compose.yml (or .env).”
Step 3 — Set the url Environment Variable Correctly Before First Startup
The url variable is the single most important setting in this file. Ghost uses it to generate every internal link — post URLs, RSS feed entries, sitemap, admin redirects, email links in the newsletter feature — at the time it first boots against an empty database. Get this wrong and you don't just get a broken link here or there: fixing it later means updating Ghost's stored configuration at the database level, not simply editing this environment variable and restarting.
Set url to the exact final public address, protocol included. If you're reverse-proxying with TLS (Step 5), that means https://blog.example.com — even though Ghost itself will only ever receive plain HTTP from Nginx on the internal network, because TLS is terminated at the proxy, not inside the container. If you're testing purely locally with no domain and no proxy yet, use http://server_ip:2368 and plan to redo the setup later once you have a real domain, rather than trying to migrate it.
Step 4 — Start the Containers
bash
docker compose up -d
Confirm both containers are running and check Ghost's boot log — on first run it waits for MySQL to finish initializing before it can connect, which can take several seconds:
bash
docker compose ps
docker compose logs -f ghost
Wait for a line like Ghost is running in production... in the log output before continuing. Press Ctrl+C to stop following the log; the containers keep running in the background.
Step 5 — Reverse Proxy With Nginx and Enable TLS (Recommended)
For anything beyond local testing, don't leave port 2368 reachable from the internet-facing interface at all — put Nginx in front of it and let Nginx be the only thing the public can reach. Edit the ports mapping in docker-compose.yml so it binds to the loopback interface only:
yaml
ports:
- "127.0.0.1:2368:2368"
Apply the change:
bash
docker compose up -d
Then add a server block for the domain, following the same proxy_pass pattern as the earlier Nginx reverse-proxy tutorial. Create the file first:
With the plain-HTTP vhost working, issue a certificate and let Certbot rewrite the block to redirect HTTP to HTTPS:
bash
sudo certbot --nginx -d blog.example.com
“Don't open port 2368 in your firewall. With the mapping bound to 127.0.0.1, nothing outside the server can reach Ghost directly even if you tried to allow it — only Nginx, running on the same host, can. If you use ufw, you only need the rule that already allows Nginx (e.g. sudo ufw allow 'Nginx Full') from the earlier tutorial; no new rule is needed for Ghost itself.”
Step 6 — Complete the Ghost Setup Wizard
Visit the /ghost path on your configured URL — https://blog.example.com/ghost. This opens Ghost's first-run setup wizard, where you set the site title and create the first administrator account (email and password). This account is separate from anything at the operating-system or database level; keep its password somewhere safe, since it's the only way into the admin panel until you invite additional staff users.
Step 7 — Verify the Installation
Confirm the stack actually works end to end, not just that the containers are up:
From the admin panel (/ghost), write and publish a short test post.
Load the public blog URL (https://blog.example.com) in a browser and confirm the post appears there.
Restart the containers and confirm the post is still there afterward — this proves the named volumes are correctly persisting both the database and Ghost's content directory, not just holding data inside an ephemeral container filesystem.
bash
docker compose restart
docker compose ps
Reload the public blog URL once both containers report as running again. The test post should still be there. For a stronger check, tear the containers down entirely and bring them back up — the volumes persist independently of the containers that mount them:
bash
docker compose down
docker compose up -d
Troubleshooting: Blank Page or Stuck on "Site Is Currently Being Updated"
If the public URL loads blank, or shows a persistent "Site is currently being updated" message that never clears, the almost-always cause is that the url environment variable doesn't match how the site is actually being accessed — for example url is set to https://blog.example.com but you're loading it over plain http://, or the domain in the URL doesn't match the domain in the browser's address bar. Ghost checks this strictly and refuses to serve normally on a mismatch.
Fix it by correcting the url value in docker-compose.yml (or your .env file) so it exactly matches the real, final public address, then force Ghost to pick up the change — a plain restart isn't enough, since environment variables are only read from a fresh container start:
bash
docker compose up -d --force-recreate ghost
If the page is still blank after that, check the Ghost container's logs for a database connection error rather than a URL problem — that usually means the db container isn't finished initializing yet, or the password in the ghost service's environment doesn't match MYSQL_ROOT_PASSWORD on the db service: