Ghost CMS Setup: Deploy Your Own Professional Publishing Platform in Docker

Complete guide to self-hosting Ghost CMS with Docker Compose. Step-by-step setup for database configurations, mail parameters, and reverse proxy settings.

Database Configuration and Backend Bindings

Ghost CMS supports two primary database configurations: SQLite for local development or lightweight personal logs, and MySQL 8 for robust production environments. In a Docker-based deployment, configuring these bindings requires setting specific environment variables on the Ghost container to map connection parameters.

MySQL 8 Production Binding

For production scaling, a dedicated MySQL service container is linked to the Ghost application service. The environment variables instruct the Ghost instance to use the MySQL client driver and establish a connection pool.

  • database__client: Must be set to mysql to load the MySQL client adapter.
  • database__connection__host: Points to the MySQL service hostname (typically matching the service name defined in your docker-compose.yml, such as db or mysql).
  • database__connection__user: The database user authorized to write to the schema (e.g., ghost).
  • database__connection__password: The secure password matching the MySQL user password.
  • database__connection__database: The target database name (e.g., ghost_prod).
  • database__connection__port: The port on which MySQL is listening (default: 3306).

SQLite Development/Lightweight Binding

For low-resource hosting, SQLite resides directly within the application workspace, avoiding the memory overhead of a separate database server.

  • database__client: Must be set to sqlite3.
  • database__connection__filename: The absolute path within the container where the SQLite database file will be stored. By default, this is /var/lib/ghost/content/data/ghost.db.
  • Note: Ensure that the directory containing this database path is backed by a persistent Docker volume to prevent data loss when container instances recycle.

Transactional Mail Configuration (SMTP)

Ghost relies on transactional email delivery to handle administrative tasks such as team member invitations, staff password resets, and user authentication links. Unlike newsletter bulk delivery (which is configured via Mailgun APIs directly in the Ghost admin panel), transactional emails require SMTP parameters injected at container initialization.

The following configuration variables map host, port, authentication, and secure socket settings:

mail__transport=SMTP
mail__options__host=smtp.mailgun.org
mail__options__port=587
mail__options__auth__user=postmaster@yourdomain.com
mail__options__auth__pass=your_secure_smtp_password
mail__options__secureConnection=false

Configuration Details

  1. mail__transport: Defines the mail delivery engine. Set this to SMTP.
  2. mail__options__host: The fully qualified domain name (FQDN) of your mail server or transactional relay provider (e.g., Mailgun, SendGrid, Amazon SES, or Postmark).
  3. mail__options__port: Use port 587 for connection upgrade via STARTTLS (recommended) or port 465 for explicit SSL wrapping.
  4. mail__options__auth__user: The SMTP login identifier.
  5. mail__options__auth__pass: The SMTP password or API credential token.
  6. mail__options__secureConnection: Set to false when using port 587 (enabling opportunistic TLS upgrading) or true when forcing SSL socket connection on port 465.

Docker Compose Configuration (docker-compose.yml)

The standard deployment configuration encapsulates the Ghost container alongside a MySQL 8 database service within a isolated virtual network.

Create a directory on your VPS and define the following docker-compose.yml manifest:

version: '3.8'

services:
  ghost:
    image: ghost:5-alpine
    container_name: ghost_app
    restart: always
    ports:
      - "2368:2368"
    environment:
      # The canonical URL for your site. Must match public access address.
      url: https://yourdomain.com

      # Database Connections
      database__client: mysql
      database__connection__host: db
      database__connection__user: ghost
      database__connection__password: DB_USER_PASSWORD_SECURE
      database__connection__database: ghost_prod

      # Transactional SMTP Mail Settings
      mail__transport: SMTP
      mail__options__host: smtp.mailgun.org
      mail__options__port: 587
      mail__options__auth__user: postmaster@yourdomain.com
      mail__options__auth__pass: SMTP_RELAY_PASSWORD_SECURE
      mail__options__secureConnection: 'false'
    volumes:
      - ghost_content:/var/lib/ghost/content
    depends_on:
      - db

  db:
    image: mysql:8.0
    container_name: ghost_db
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: DB_ROOT_PASSWORD_SECURE
      MYSQL_DATABASE: ghost_prod
      MYSQL_USER: ghost
      MYSQL_PASSWORD: DB_USER_PASSWORD_SECURE
    volumes:
      - db_data:/var/lib/mysql

volumes:
  ghost_content:
    driver: local
  db_data:
    driver: local

Internal Setup and Volume Mapping

To preserve blog posts, uploaded media, themes, and application data, the containers require persistent storage bindings.

Data Directories inside the Container

  1. Ghost Content: Located at /var/lib/ghost/content. It holds uploaded assets, active themes, system logs, dynamic configurations, and internal media.
  2. MySQL Data: Located at /var/lib/mysql. It stores table definitions, indexes, transaction logs, and user metadata.

Resolving Ownership and File Permission Conflicts

The official Ghost Docker image runs under an unprivileged system user (ghost:ghost, UID/GID 999). When using host volume mounts instead of named volumes, permissions must be explicitly aligned.

Run the following commands on the host after launching the containers to correct directory permissions if file creation issues occur:

# Correcting permissions for host-mounted content directories
sudo chown -R 999:999 /path/to/host/ghost/content

To run Docker Compose in the background:

docker compose up -d

To inspect startup logs and ensure the database migration has run successfully:

docker compose logs -f ghost

Nginx Reverse Proxy with SSL Termination

Because Ghost runs internally on port 2368, an external reverse proxy is required to secure public HTTP/HTTPS ports (80/443), manage SSL/TLS handshakes, and forward web requests to the underlying Docker containers.

Nginx Configuration Manifest

Save the virtual host block to /etc/nginx/sites-available/ghost:

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com;

    # Certbot challenge path
    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    # Redirect all HTTP requests to HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name yourdomain.com;

    # SSL Certs paths managed by Certbot
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # Robust cryptographic parameter protocols
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;

    # Strict Security Headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;

    # Increase body limit for large image/media uploads
    client_max_body_size 50M;

    location / {
        proxy_set_header Host $http_host;
        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 $scheme;

        # Forward traffic to the Docker mapped port
        proxy_pass http://127.0.0.1:2368;
    }
}

Enabling and Testing Nginx

To activate the virtual host, create a symlink to sites-enabled, test the syntax configuration, and reload the service:

sudo ln -s /etc/nginx/sites-available/ghost /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Obtaining SSL Certificates via Let's Encrypt Certbot

Execute the Certbot interactive client to request and renew certificate files:

sudo certbot certonly --webroot -w /var/www/html -d yourdomain.com

This ensures zero-downtime renewal using the webroot validator method configured in the port 80 location block.