[Docker] Ghost + MySQL8 + NginX + Imagor on Synology NAS
![[Docker] Ghost + MySQL8 + NginX + Imagor on Synology NAS](/content/images/size/w1200/2025/09/ghostfeaturedimage.jpg)
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.kr
→http://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
- Browse to
https://your.domain.com/ghost
- Create your first admin account
- 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:
- User Action
- You drag & drop an image into the Ghost editor (or upload via settings).
- 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).
- Ghost receives the upload and stores the original image under its
- Request for Image
- A site visitor loads a post containing that image.
- Their browser requests the image URL.
- 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.
- If the request is a normal image (e.g.,
- 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.
- 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.