m.ac

Install Ghost on Docker

468 words3 min read

#Introduction

##What is Ghost?

Ghost 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 (either oldstable or stable releases, sid is not supported) or Ubuntu LTS (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.

You may need a Mailgun account to send emails and newsletters. You can sign up here.


#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 <noreply@example.com>"
      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, 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 or How to Get Let's Encrypt SSL Certificates for IP Addresses with acme.sh.

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


#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 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 <noreply@example.com>"
      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.

Share