--- title: "Install Ghost on Docker" tags: ['Ghost', 'Docker', 'Nginx'] published: 2025-05-05 19:39:13 updated: 2026-01-15 19:05:16 excerpt: "Run Ghost with Docker Compose, MySQL, Watchtower, and either Nginx or Caddy as the public reverse proxy." --- > This file is the raw Markdown source of an m.ac post; the canonical version lives at https://m.ac/install-ghost-on-docker/ > The full index of posts and pages is at https://m.ac/llms.txt — read the index first before crawling further. ## Introduction ### What is Ghost? [Ghost](https://ghost.org/) is an open-source publishing platform for blogs, newsletters, and paid memberships. It gives you the editor, admin panel, email tooling, and subscription features in one place. This tutorial runs Ghost on Docker with MySQL, then puts it behind either Nginx or Caddy. --- ## 1. Prerequisites Make sure you have installed [**Debian Stable**](https://www.debian.org/releases/?ref=m.ac) (either **oldstable** or **stable** releases, **sid** is not supported) or [**Ubuntu LTS**](https://releases.ubuntu.com/?ref=m.ac) (Interim releases not supported). The commands below use `sudo`. If you prefer a root shell, run `sudo -i` first and drop `sudo` from the commands. The `docker` commands in this guide are not prefixed with `sudo`: add your user to the `docker` group (`sudo usermod -aG docker $USER`, then log out and back in), or prefix the `docker` commands with `sudo` yourself. Install Docker and Docker Compose by following the instructions [in this article](https://m.ac/install-docker-debian-ubuntu/). You may need a Mailgun account to send emails and newsletters. You can sign up [here](https://signup.mailgun.com/new/signup). --- ## 2. Create a Docker Compose file Create a file named `compose.yaml` at `/opt/compose.yaml`: ```bash cd /opt echo 'name: ghost services: ghost: image: ghost:6-alpine container_name: ghost restart: always ports: - 127.0.0.1:2368:2368 environment: TZ: "UTC" database__client: mysql database__connection__host: ghost_database database__connection__user: ghost_user database__connection__password: YourRandomMySQLPassword database__connection__database: ghost_database mail__transport: smtp mail__from: "Your Name " mail__options__service: Mailgun mail__options__host: smtp.mailgun.org mail__options__port: 465 mail__options__auth__user: YourMailgunEmailUsername mail__options__auth__pass: YourMailgunEmailPassword mail__options__secure: true url: https://example.com/ volumes: - ./data/ghost:/var/lib/ghost/content networks: - ghost-network ghost_database: image: mysql:8 container_name: ghost_database restart: always environment: MYSQL_ROOT_PASSWORD: YourRandomMySQLRootPassword MYSQL_DATABASE: ghost_database MYSQL_USER: ghost_user MYSQL_PASSWORD: YourRandomMySQLPassword volumes: - ./data/mysql:/var/lib/mysql networks: - ghost-network watchtower: image: nickfedor/watchtower:latest container_name: watchtower restart: always volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - WATCHTOWER_CLEANUP=true - WATCHTOWER_POLL_INTERVAL=86400 networks: - ghost-network networks: ghost-network: name: ghost-network ' | sudo tee ./compose.yaml ``` > Replace the Ghost environment settings with your own values. Pull the images and start the containers: ```bash docker compose pull docker compose up -d ``` Use `docker ps` to check that the containers are running: ```bash # docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES bd0cb912685f ghost:5-alpine "docker-entrypoint.s…" 3 seconds ago Up Less than a second 127.0.0.1:2368->2368/tcp ghost eb058d47d520 mysql:lts "docker-entrypoint.s…" 3 seconds ago Up 2 seconds 3306/tcp, 33060/tcp ghost_database b96d0acfb0f9 nickfedor/watchtower:latest "/watchtower" 3 seconds ago Up 2 seconds (health: starting) 8080/tcp watchtower ``` Use `docker logs ghost` if you want to check Ghost's startup logs: ```bash # docker logs ghost ........... [2025-05-05 19:06:38] INFO Database is in a ready state. [2025-05-05 19:06:38] INFO Ghost database ready in 5.036s [2025-05-05 19:06:39] INFO Invalidating assets for regeneration [2025-05-05 19:06:39] INFO Adding offloaded job to the inline job queue [2025-05-05 19:06:39] INFO Scheduling job mentions-email-report at 13 33 * * * *. Next run on: Mon May 05 2025 19:33:13 GMT+0000 (Coordinated Universal Time) [2025-05-05 19:06:39] INFO Adding one-off job to inlineQueue with current length = 0 called 'members-migrations' [2025-05-05 19:06:39] INFO Stripe not configured - skipping migrations [2025-05-05 19:06:40] INFO URL Service ready in 1156ms [2025-05-05 19:06:40] INFO Adding offloaded job to the inline job queue [2025-05-05 19:06:40] INFO Scheduling job clean-expired-comped at 59 13 3 * * *. Next run on: Tue May 06 2025 03:13:59 GMT+0000 (Coordinated Universal Time) [2025-05-05 19:06:40] INFO Adding offloaded job to the inline job queue [2025-05-05 19:06:40] INFO Scheduling job clean-tokens at 35 51 0 * * *. Next run on: Tue May 06 2025 00:51:35 GMT+0000 (Coordinated Universal Time) [2025-05-05 19:06:40] INFO Ghost booted in 6.953s [2025-05-05 19:06:40] INFO Adding offloaded job to the inline job queue [2025-05-05 19:06:40] INFO Scheduling job update-check at 12 0 17 * * *. Next run on: Tue May 06 2025 17:00:12 GMT+0000 (Coordinated Universal Time) [2025-05-05 19:06:40] INFO Running milestone emails job on Tue May 06 2025 19:06:40 GMT+0000 (Coordinated Universal Time) ``` Ghost is now listening on `127.0.0.1:2368`. Add a reverse proxy so the site is reachable from the public internet. --- ## 3. Set up an Nginx reverse proxy Install Nginx from [N.WTF](https://n.wtf/install/), then install the package: ```bash sudo apt install nginx-extras -y ``` Create a configuration file at `/etc/nginx/sites-available/example.com`: ```bash sudo bash -c 'cat > /etc/nginx/sites-available/example.com << "EOF" upstream ghost { server 127.0.0.1:2368; } server { listen 443 ssl; listen [::]:443 ssl; listen 443 quic; listen [::]:443 quic; server_name example.com; access_log /var/log/nginx/example.com.access.log; error_log /var/log/nginx/example.com.error.log; add_header Alt-Svc '"'"'h3=":443"; ma=86400'"'"'; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; add_header X-Frame-Options SAMEORIGIN; add_header X-Content-Type-Options nosniff; add_header Referrer-Policy "strict-origin-when-cross-origin"; location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; proxy_set_header X-NginX-Proxy true; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection ""; proxy_redirect off; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 300; proxy_send_timeout 300; proxy_read_timeout 300; send_timeout 300; proxy_pass http://ghost; proxy_hide_header X-powered-by; } ssl_protocols TLSv1.3; ssl_ecdh_curve X25519:prime256v1:secp384r1; ssl_prefer_server_ciphers off; ssl_certificate /etc/nginx/ssl/example.com.crt; ssl_certificate_key /etc/nginx/ssl/example.com.key; } EOF' sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/example.com ``` > Replace `example.com` and the SSL certificate paths with your own domain and certificate files. This config assumes the certificates already exist under `/etc/nginx/ssl`. To issue them, see [How to Use nginx-acme on Debian/Ubuntu to Auto-Issue Let's Encrypt SSL for NGINX](/nginx-acme-auto-ssl-debian-ubuntu/) or [How to Get Let's Encrypt SSL Certificates for IP Addresses with acme.sh](/letsencrypt-ip-certificate/). Test the Nginx configuration and reload the service: ```bash sudo nginx -t sudo nginx -s reload ``` Visit `https://example.com/ghost/` in your browser to open the Ghost admin screen. ![Ghost admin welcome screen after a fresh Docker install](https://macdn.net/2026/07/07/3ym9d/ghost-admin.png) --- ## 4. Set up Caddy as a reverse proxy If you do not want to manage certificates by hand, use Caddy instead of Nginx. It is less ceremony, which is nice when you would rather blog than negotiate TLS. [Caddy](https://caddyserver.com/) is an extensible, cross-platform, open-source web server written in Go. It requests and renews TLS certificates automatically. Add the following lines to your Docker Compose YAML file: ```yaml caddy: image: caddy:alpine container_name: caddy restart: always ports: - "80:80/tcp" - "443:443/tcp" - "443:443/udp" volumes: - ./caddy/etc/caddy:/etc/caddy - ./caddy/data:/data - ./caddy/config:/config - ./caddy/srv:/srv networks: - ghost-network ``` Or replace the Compose file with this full version: ```bash cd /opt echo 'name: ghost services: ghost: image: ghost:6-alpine container_name: ghost restart: always ports: - 127.0.0.1:2368:2368 environment: TZ: "UTC" database__client: mysql database__connection__host: ghost_database database__connection__user: ghost_user database__connection__password: YourRandomMySQLPassword database__connection__database: ghost_database mail__transport: smtp mail__from: "Your Name " mail__options__service: Mailgun mail__options__host: smtp.mailgun.org mail__options__port: 465 mail__options__auth__user: YourMailgunEmailUsername mail__options__auth__pass: YourMailgunEmailPassword mail__options__secure: true url: https://example.com/ volumes: - ./data/ghost:/var/lib/ghost/content networks: - ghost-network ghost_database: image: mysql:8 container_name: ghost_database restart: always environment: MYSQL_ROOT_PASSWORD: YourRandomMySQLRootPassword MYSQL_DATABASE: ghost_database MYSQL_USER: ghost_user MYSQL_PASSWORD: YourRandomMySQLPassword volumes: - ./data/mysql:/var/lib/mysql networks: - ghost-network caddy: image: caddy:alpine container_name: caddy restart: always ports: - "80:80/tcp" - "443:443/tcp" - "443:443/udp" volumes: - ./caddy/etc/caddy:/etc/caddy - ./caddy/data:/data - ./caddy/config:/config - ./caddy/srv:/srv networks: - ghost-network watchtower: image: nickfedor/watchtower:latest container_name: watchtower restart: always volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - WATCHTOWER_CLEANUP=true - WATCHTOWER_POLL_INTERVAL=86400 networks: - ghost-network networks: ghost-network: name: ghost-network ' | sudo tee ./compose.yaml ``` Create a `Caddyfile`: ```bash sudo mkdir -p ./caddy/etc/caddy sudo bash -c 'cat > ./caddy/etc/caddy/Caddyfile << "EOF" example.com { header { -Server Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" Referrer-Policy "strict-origin-when-cross-origin" } reverse_proxy http://ghost:2368 { header_up Host {host} } encode zstd gzip } EOF' ``` Pull all the images and start the containers: ```bash docker compose pull docker compose up -d ``` Wait a few minutes, then visit `https://example.com/` in your browser. > Replace `example.com` with your own domain name in the `Caddyfile`. Also make sure your DNS A/AAAA records point to this server.