[Docker] Ghost + MySQL8 + NginX + Imagor on Synology NAS

[Docker] Ghost + MySQL8 + NginX + Imagor on Synology NAS

When most people think of blogging platforms, WordPress is the default. It’s powerful, flexible, and has a massive ecosystem. But with that comes complexity. If you want something more streamlined and focused on publishing, Ghost might be worth considering.


WordPress vs Ghost: A Straightforward Comparison

Before diving into the installation, let’s quickly compare the two most common blogging CMSs.

  • WordPress Pros
    • Huge plugin ecosystem: SEO, e-commerce, memberships, galleries—you name it.
    • Almost universal hosting support.
    • Easy to start with pre-made themes and builders.
  • WordPress Cons
    • Performance overhead (lots of plugins, heavy themes).
    • Constant maintenance (security updates, plugin conflicts).
    • Writing experience is not as clean—sometimes buried under admin clutter.
  • Ghost Pros
    • Clean, distraction-free writing interface (similar to Medium).
    • Built on Node.js with performance in mind.
    • Native support for memberships and newsletters.
  • Ghost Cons
    • Smaller ecosystem—fewer plugins or integrations.
    • Requires Docker/Node.js setup, not beginner-friendly.
    • No built-in comment system.

👉 In short: WordPress is still king for flexibility, while Ghost is ideal if you want a fast, focused publishing tool.


Folder Structure

Here’s the directory layout used in this guide (without .env):

ghost-docker/
├── docker-compose.yml
├── config/
│ ├── conf.d/ # MySQL custom config
│ ├── ghost/
│ │ └── config.production.json
│ └── nginx/
│ └── nginx.conf
├── mysql/ # MySQL data
├── content/ # Ghost content (themes, images, posts)
├── cache/ # Imagor cache
└── logs/ # Nginx logs

docker-compose.yml

# docker-compose.yml
# ------------------------------------------------------------
# Ghost + MySQL + Imagor + Nginx (single bridge network)
# ⚠️ This file contains example passwords/tokens/ports.
#    Replace them with your own secure values before deployment.
# ------------------------------------------------------------

version: "3.9"

networks:
  ghost-net:
    driver: bridge

services:
  # ----------------------------------------------------------
  # MySQL 8.4 (Ghost officially supports MySQL 8.x, not MariaDB)
  # ----------------------------------------------------------
  mysql:
    image: mysql:8.4
    container_name: ghost-db
    restart: unless-stopped
    environment:
      TZ: "Asia/Seoul"

      # ⚠️ CHANGE: replace with a secure password (e.g. openssl rand -base64 24)
      MYSQL_ROOT_PASSWORD: "REPLACE_ME_ROOT_SECRET"
      MYSQL_DATABASE: "ghost"
      MYSQL_USER: "ghost"

      # ⚠️ CHANGE: must match the ghost service DB password
      MYSQL_PASSWORD: "REPLACE_ME_GHOST_DB_SECRET"
    command:
      # UTF-8 (4-byte) support
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_0900_ai_ci

      # Large uploads/inserts
      - --max_allowed_packet=268435456

      # Adjust to your Synology NAS memory
      - --innodb-buffer-pool-size=512M

      # Fixed server timezone
      - --default-time-zone=+09:00
    volumes:
      # ⚠️ Backup this data directory regularly
      - ./mysql:/var/lib/mysql
      # Optional custom my.cnf snippets
      - ./config/conf.d:/etc/mysql/conf.d
    healthcheck:
      # ✅ Note: $$ escapes $ so Compose does not expand it locally
      test: ["CMD-SHELL", "mysql -h127.0.0.1 -uroot -p\"$$MYSQL_ROOT_PASSWORD\" -e 'SELECT 1' >/dev/null 2>&1 || exit 1"]
      start_period: 5m
      interval: 5s
      timeout: 3s
      retries: 60
    networks: [ghost-net]

  # ----------------------------------------------------------
  # Ghost 6 (content lives in /var/lib/ghost/content)
  # ----------------------------------------------------------
  ghost:
    image: ghost:6
    container_name: ghost-app
    restart: unless-stopped
    depends_on:
      mysql:
        condition: service_healthy
    environment:
      TZ: "Asia/Seoul"

      # ⚠️ Public URL of your blog (HTTPS strongly recommended)
      url: "https://blog.wody.kr"

      # Internal binding (proxied by Nginx)
      server__port: "2368"
      server__host: "0.0.0.0"

      # DB connection info
      database__client: "mysql"
      database__connection__host: "mysql"
      database__connection__user: "ghost"
      # ⚠️ Must match MYSQL_PASSWORD above
      database__connection__password: "REPLACE_ME_GHOST_DB_SECRET"
      database__connection__database: "ghost"
    volumes:
      # ⚠️ WARNING: mounting here overwrites the built-in symlink
      #     (/var/lib/ghost/content -> versions/6.x.y/content).
      #     If you want to preserve that symlink structure, mount
      #     /var/lib/ghost (the parent directory) instead and copy
      #     the original structure to the host.
      - ./content:/var/lib/ghost/content

      # Optional: only if you want to manage production config externally
      - ./config/ghost/config.production.json:/var/lib/ghost/config.production.json:ro
    expose:
      - "2368"  # only available to the internal Docker network
    shm_size: 512m
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
    networks: [ghost-net]

  # ----------------------------------------------------------
  # Imagor (image processing proxy)
  # ----------------------------------------------------------
  imagor:
    image: shumc/imagor:latest
    container_name: imagor
    restart: unless-stopped
    environment:
      TZ: "Asia/Seoul"
      PORT: "8000"

      # ⚠️ NOT SAFE for production: UNSAFE=1 allows unsigned transforms,
      #    can be abused for DoS. Use UNSAFE=0 + IMAGOR_SECRET in production.
      IMAGOR_UNSAFE: "1"

      # Ghost as the source of images
      HTTP_LOADER_BASE_URL: "http://ghost-app:2368"
      HTTP_LOADER_ALLOWED_SOURCES: "ghost-app"

      # Cache directory
      FILE_STORAGE_BASE_DIR: "/cache"

      # 🔐 Production example (recommended):
      # IMAGOR_UNSAFE: "0"
      # IMAGOR_SECRET: "REPLACE_ME_IMAGOR_SECRET"
    volumes:
      - ./cache:/cache   # ⚠️ Manage cache size/rotation
    expose:
      - "8000"
    networks: [ghost-net]

  # ----------------------------------------------------------
  # Nginx reverse proxy
  # ----------------------------------------------------------
  nginx:
    image: nginx:stable
    container_name: ghost-nginx
    restart: unless-stopped
    depends_on:
      - ghost
      - imagor
    volumes:
      # ⚠️ nginx.conf must handle:
      #     - reverse proxy to Ghost
      #     - proxy to Imagor (signed URLs if UNSAFE=0)
      #     - ActivityPub proxy rules (ap.ghost.org)
      #     - proper X-Forwarded-* headers
      - ./config/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./logs:/var/log/nginx   # log rotation needed
    ports:
      # ⚠️ Exposed port. In production, you normally put Nginx behind 443/HTTPS
      - "2368:2368"
    networks: [ghost-net]

config.production.json

{
  "mail": {
    "transport": "SMTP",
    "options": {
      "service": "Gmail",
      "auth": {
        "user": "********@gmail.com",   // NOTE: replace
        "pass": "********"              // NOTE: replace (app password)
      }
    }
  }
}

nginx.conf

# ========================================================
# NGINX (behind Synology Reverse Proxy)
# WARNING: Do not modify any line unless you know the impact.
# ========================================================

upstream ghost_upstream {
    server ghost-app:2368;
    keepalive 32;
}

upstream imagor_upstream {
    server imagor:8000;
    keepalive 32;
}

server {
    listen 2368;
    listen [::]:2368;

    # Global limits & IO
    client_max_body_size 50m;
    sendfile on;

    # ----------------------------------------------------
    # ActivityPub → ap.ghost.org
    # Why: These endpoints must be forwarded exactly as-is.
    # Keep headers and matcher order intact.
    # ----------------------------------------------------
    location ^~ /.ghost/activitypub/ {
        proxy_pass https://ap.ghost.org;
        proxy_http_version 1.1;
        proxy_ssl_server_name on;

        # Required for site mapping and admin authentication
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Proto https;   # we're behind HTTPS at the edge
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header Authorization     $http_authorization;

        add_header X-Content-Type-Options "nosniff" always;
    }

    # Exact match for WebFinger
    location = /.well-known/webfinger {
        proxy_pass https://ap.ghost.org;
        proxy_http_version 1.1;
        proxy_ssl_server_name on;

        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP         $remote_addr;

        add_header X-Content-Type-Options "nosniff" always;
    }

    # Exact match for NodeInfo
    location = /.well-known/nodeinfo {
        proxy_pass https://ap.ghost.org;
        proxy_http_version 1.1;
        proxy_ssl_server_name on;

        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP         $remote_addr;

        add_header X-Content-Type-Options "nosniff" always;
    }

    # ----------------------------------------------------
    # Imagor prefix: /_im/... → /unsafe/... (or signed URL if enabled)
    # Note: Do not change rewrite/caching without understanding the impact.
    # Fix: use (.*) to capture the remainder of the path.
    # ----------------------------------------------------
    location ^~ /_im/ {
        rewrite ^/_im/(.*)$ /unsafe/$1 break;
        proxy_pass http://imagor_upstream;
        proxy_http_version 1.1;

        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP         $remote_addr;

        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # ----------------------------------------------------
    # Static assets via Ghost
    # Rationale: long-lived cache; case-insensitive ext match.
    # ----------------------------------------------------
    location ~* \.(css|js|ico|svg|ttf|otf|woff2?)$ {
        expires 30d;
        access_log off;

        proxy_pass http://ghost_upstream;
        proxy_http_version 1.1;

        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP         $remote_addr;
    }

    # ----------------------------------------------------
    # Everything else → Ghost app
    # Keep after ActivityPub/Imagor locations.
    # Includes WebSocket upgrade headers.
    # ----------------------------------------------------
    location / {
        proxy_pass http://ghost_upstream;
        proxy_http_version 1.1;

        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP         $remote_addr;

        # WebSocket/HTTP upgrade
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        "upgrade";

        # Optional timeouts (safe defaults)
        proxy_connect_timeout  60s;
        proxy_send_timeout     60s;
        proxy_read_timeout     60s;
    }

    # Logs
    access_log /var/log/nginx/ghost.access.log;
    error_log  /var/log/nginx/ghost.error.log warn;
}

warning: Any change to headers (Host, X-Forwarded-*, Authorization), location order/matchers, or proxy_pass targets can immediately cause 403, broken ActivityPub discovery, or Ghost Admin “Network” tab failures.


Why Nginx? (Practical Reasons)

Ghost’s Node.js server works fine on its own, but Nginx adds very real benefits:

  • Imagor integration – without Nginx rewriting /im/ paths, Imagor can’t function.
  • Static asset caching – e.g., 30-day browser cache on CSS/JS for faster repeat visits.
  • Upload buffering – handle larger file uploads gracefully (client_max_body_size).
  • Custom error pages – show friendly messages during restarts or downtime.
  • ActivityPub / Admin Network – Ghost’s Network tab and ActivityPub endpoints require proxying certain paths (/.ghost/activitypub/*, /.well-known/webfinger, /.well-known/nodeinfo) to ap.ghost.org. Without a reverse proxy handling these routes, the admin panel will break with “Loading interrupted” errors. **Important**

👉 In short: Imagor makes Nginx mandatory, and the ActivityPub integration makes a reverse proxy non-optional. Everything else is quality-of-life improvement.


Reverse Proxy & WebSocket (DSM)

On DSM:

  • Go to Control Panel → Application Portal → Reverse Proxy.
  • Map blog.wody.krhttp://NAS_IP:2368.
  • Enable WebSocket support.

MySQL Healthcheck Timing

MySQL initialization on NAS can be slow. The current config allows up to 10 minutes before Ghost starts.
👉 After first setup, you can shorten this to something like:

start_period: 60s
interval: 10s
retries: 5

First Ghost Setup

  1. Browse to https://your.domain.com/ghost
  2. Create your first admin account
  3. Configure the blog name & basic settings

Image Upload & Delivery Flow

When you upload an image to Ghost, here’s what actually happens inside this stack:

  1. User Action
    • You drag & drop an image into the Ghost editor (or upload via settings).
  2. Ghost Application
    • Ghost receives the upload and stores the original image under its content/images/ directory.
    • Metadata about the image is recorded in the Ghost database (MySQL).
  3. Request for Image
    • A site visitor loads a post containing that image.
    • Their browser requests the image URL.
  4. Nginx Reverse Proxy
    • If the request is a normal image (e.g., /content/images/...), Nginx passes it directly to Ghost.
    • If the request uses the Imagor prefix (e.g., /_im/800x600/...), Nginx rewrites the path and routes it to Imagor instead.
  5. Imagor Image Processor
    • Imagor fetches the original image from Ghost.
    • It applies transformations (resize, crop, compress, etc.).
    • The processed version is cached locally in the cache/ directory.
  6. Response to Browser
    • Imagor returns the optimized image back through Nginx.
    • Nginx adds caching headers so repeat visits load even faster.
    • The user sees the optimized image in their browser.

👉 In short:
User → Ghost (store) → Nginx (route) → Imagor (process/cache) → Nginx → Browser


Comments in Ghost

Ghost does not have built-in comments. Consider self-hosted solutions like:

  • Remark42 – lightweight, fast.
  • Isso – SQLite-based, minimal.
  • Commento – privacy-friendly, modern UI.

Final Thoughts

Ghost on Synology NAS with Docker is a great fit if you want speed and simplicity. The Ghost + MySQL 8 + Nginx + Imagor stack provides a clean publishing experience with optimized images and caching, all behind DSM’s reverse proxy.

Do it when you have enough time—the first run takes patience. But once it’s up, you’ll have a modern, fast blogging platform ready to go.