Deploying Ghost with Docker and LetsEncrypt
A quick and dirty guide for setting up Ghost with Docker Compose and SSL using Let's Encrypt and Nginx
When I first put this site online, I had a harder time than expected finding good guides for running Ghost with Docker and Let’s Encrypt without a bunch of unnecessary moving parts. I already had a reverse proxy setup I was using for a few other services, so I ended up adapting that pattern for this blog.
The nice thing about Ghost is that the application side is simple. There’s an official Docker image, and most of the real work is just making sure the environment variables line up properly. The most important one is url, because Ghost uses it when generating links, redirects, and references throughout the site. If that value is wrong, you’ll eventually notice things pointing to the wrong hostname or protocol.
This is what the Ghost service looks like in my current setup:
ghost:
image: ghost:6-alpine
container_name: ghost-ghost-1
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
url: ${GHOST_URL}
database__client: mysql
database__connection__host: db
database__connection__user: ${MYSQL_USER}
database__connection__password: ${MYSQL_PASSWORD}
database__connection__database: ${MYSQL_DATABASE}
database__connection__charset: utf8mb4
VIRTUAL_HOST: ${VHOST}
LETSENCRYPT_HOST: ${VHOST}
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
NODE_OPTIONS: --max-old-space-size=384
volumes:
- ./ghost:/var/lib/ghost/content
The url value should match the real public URL of the site. In my case that’s https://hexed.io, so Ghost knows to generate HTTPS links instead of sending people back to something like localhost. The content directory is also mounted to ./ghost, which makes upgrades and backups much easier since themes, images, and settings are stored outside the container.
For the database, I’m using MySQL 8. That part is pretty standard, but it’s worth explicitly creating a database and user for Ghost instead of treating everything as root-only configuration.
db:
image: mysql:8.0
container_name: ghost-db-1
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
command: >
mysqld
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--default-authentication-plugin=mysql_native_password
healthcheck:
test: ["CMD","mysqladmin","ping","-h","127.0.0.1","-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 20
volumes:
- db_data:/var/lib/mysql
The important thing here is that the Ghost container and MySQL container agree on the database name, username, and password. I’m also explicitly setting utf8mb4, which is what you want for a modern Ghost install.
Once Ghost and MySQL are configured, the rest of the setup is really about routing and TLS. For that I’m using jwilder/nginx-proxy together with docker-letsencrypt-nginx-proxy-companion.
The proxy service looks like this:
proxy:
image: jwilder/nginx-proxy:alpine
container_name: proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
DEFAULT_HOST: ${GHOST_HOST}
TRUST_DOWNSTREAM_PROXY: "false"
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- letsencrypt_certs:/etc/nginx/certs
- letsencrypt_vhosts:/etc/nginx/vhost.d
- letsencrypt_html:/usr/share/nginx/htmlPort 80 needs to remain open so Let’s Encrypt can complete HTTP validation, and port 443 is where the site is ultimately served over HTTPS. The proxy watches Docker and routes traffic based on container environment variables, which keeps the setup pretty lean.
I also mount a small proxy.conf to override a few defaults and bump the read timeout:
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $proxy_connection;
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 $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Ssl $proxy_x_forwarded_ssl;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header Proxy "";
proxy_read_timeout 300s;
The Let’s Encrypt companion is even simpler:
letsencrypt:
image: jrcs/letsencrypt-nginx-proxy-companion
container_name: letsencrypt
restart: unless-stopped
depends_on: [proxy]
environment:
NGINX_PROXY_CONTAINER: proxy
DEFAULT_EMAIL: ${LETSENCRYPT_EMAIL}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt_certs:/etc/nginx/certs
- letsencrypt_vhosts:/etc/nginx/vhost.d
- letsencrypt_html:/usr/share/nginx/html
This container watches for services that advertise VIRTUAL_HOST and LETSENCRYPT_HOST, then provisions and renews certificates automatically. That’s also why my current setup no longer needs a separate app-specific Nginx container sitting in front of Ghost. The proxy handles that routing directly.
On the Ghost side, these are the values that matter for that integration:
environment:
url: https://hexed.io
VIRTUAL_HOST: hexed.io,www.hexed.io
LETSENCRYPT_HOST: hexed.io,www.hexed.io
LETSENCRYPT_EMAIL: you@example.comAs long as those match your actual domain and DNS is pointed at the server, the proxy and Let’s Encrypt companion will take care of the rest.
The remaining volumes are just there to persist the database and certificate state:
volumes:
db_data:
letsencrypt_certs:
letsencrypt_vhosts:
letsencrypt_html:
At that point the whole stack is ready to run on any VPS with Docker installed. Once DNS is in place, bringing the site up is just:
docker compose up -d
That’s the entire setup I’m using now. It’s fairly minimal, easy to maintain, and avoids a bunch of extra glue. Ghost talks to MySQL, nginx-proxy handles routing, the Let’s Encrypt companion handles certificates, and the content directory lives outside the container so backups and upgrades stay simple.