Running Docker on Windows WSL2/Ubuntu (Part 5) – Install Ghost with MySQL, Nginx, and phpMyAdmin

Learn how to deploy Ghost with MySQL 8, hardened Nginx reverse proxy, and phpMyAdmin on Docker inside WSL2/Ubuntu. Includes SEO-ready configuration, custom headers, SSL, and networking with Nginx Proxy Manager.

Running Docker on Windows WSL2/Ubuntu (Part 5) – Install Ghost with MySQL, Nginx, and phpMyAdmin

Introduction: Production-Ready Ghost

In earlier parts of this series, we set up WSL2 + Docker Engine and secured it with Nginx Proxy Manager (NPM). Now it’s time to deploy something you can actually run in production: a Ghost blog, backed by MySQL 8, fronted by a dedicated Nginx reverse proxy, and supported by phpMyAdmin for database management.

Ghost’s built-in Node.js server works fine for quick tests, but it has limitations in production—especially for SSL termination, caching, and custom headers. That’s why we’ll place Nginx in front of Ghost, just like you would on a “real” Linux VPS. Combined with NPM on the edge, this creates a clean, scalable architecture.

By the end of this post, you’ll have a fully operational Ghost blog that’s secure, optimized, and manageable.


Step 1: Folder & File Structure

We’ll keep things organized under ~/docker/ghost:

~/docker/ghost/
 ├─ docker-compose.yaml
 ├─ ads.txt
 ├─ content/     # Ghost content (posts, images, themes)
 ├─ mysql/       # MySQL data
 └─ config/
      ├─ ghost/config.production.json
      ├─ nginx/nginx.conf
      ├─ pma/pma-php.ini
      └─ pma/config.user.inc.php

👉 Important: ads.txt must exist, even as an empty file. If it’s missing, docker compose up -d will fail due to the bind mount.

You can create the whole folder structure in one go with:

mkdir -p ./{content,mysql,config/ghost,config/nginx,config/pma} \
  && touch ./ads.txt \
  && touch ./docker-compose.yaml \
  && touch ./config/ghost/config.production.json \
  && touch ./config/nginx/nginx.conf \
  && touch ./config/pma/pma-php.ini \
  && touch ./config/pma/config.user.inc.php

This way, all required directories and placeholder files are ready before writing configuration.


Step 2: Docker Compose Setup

We’ll use two networks:

  • ghost-internal → internal-only communication (MySQL, phpMyAdmin, Ghost, Nginx)
  • edge → shared external network for NPM access (only Ghost + Nginx join)

docker-compose.yaml:

networks:
  ghost-internal:
    driver: bridge
  edge:
    external: true

volumes:
  pma_sessions:

services:
  mysql:
    image: mysql:8.4
    container_name: ghost-mysql
    restart: unless-stopped
    environment:
      TZ: "${TZ}"
      MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
      MYSQL_DATABASE: "${MYSQL_DATABASE}"
      MYSQL_USER: "${MYSQL_USER}"
      MYSQL_PASSWORD: "${MYSQL_PASSWORD}"
      MYSQL_ROOT_HOST: "%"
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_0900_ai_ci
      - --max_allowed_packet=268435456
      - --innodb-buffer-pool-size=512M
    volumes:
      - ./mysql:/var/lib/mysql
      # If you actually use custom MySQL conf, create ./config/conf.d and uncomment:
      # - ./config/conf.d:/etc/mysql/conf.d
    healthcheck:
      test: ["CMD-SHELL", "mysql -h127.0.0.1 -uroot -p\"$$MYSQL_ROOT_PASSWORD\" -e 'SELECT 1' >/dev/null 2>&1 || exit 1"]
      start_period: 3m
      interval: 5s
      timeout: 3s
      retries: 60
    networks:
      - ghost-internal

  ghost:
    image: ghost:6
    container_name: ghost-app
    restart: unless-stopped
    depends_on:
      mysql:
        condition: service_healthy
    environment:
      TZ: "${TZ}"
      url: "${GHOST_URL}"
      server__port: "2368"
      server__host: "0.0.0.0"
      database__client: "mysql"
      database__connection__host: "mysql"
      database__connection__user: "${MYSQL_USER}"
      database__connection__password: "${MYSQL_PASSWORD}"
      database__connection__database: "${MYSQL_DATABASE}"
    volumes:
      - ./content:/var/lib/ghost/content
      - ./config/ghost/config.production.json:/var/lib/ghost/config.production.json:ro
    expose:
      - "2368"
    shm_size: "2g"
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
    networks:
      - ghost-internal       # ← private only (no edge)

  nginx:
    image: nginx:1.29.1
    container_name: ghost-nginx
    restart: unless-stopped
    depends_on:
      - ghost
    volumes:
      - ./config/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./ads.txt:/srv/public/ads.txt:ro
    # no host port mapping needed; 
    # NPM talks to ghost-nginx:2368 over 'edge'
    networks:
      - ghost-internal       # app side
      - edge                 # public-facing via NPM

  phpmyadmin:
    image: phpmyadmin:latest
    container_name: ghost-pma
    restart: unless-stopped
    depends_on:
      mysql:
        condition: service_healthy
    environment:
      TZ: "${TZ}"
      PMA_ABSOLUTE_URI: "${PMA_ABSOLUTE_URI}"
      UPLOAD_LIMIT: "512M"
    volumes:
      - pma_sessions:/sessions
      - ./config/pma/config.user.inc.php:/etc/phpmyadmin/config.user.inc.php:ro
      - ./config/pma/pma-php.ini:/usr/local/etc/php/conf.d/zz-pma.ini:ro
    networks:
      - ghost-internal       # private; exposed via Nginx subpath /pma/

Why Ghost does not join edge

  • Only Nginx needs to be reachable by NPM (on edge).
  • Ghost, MySQL, and phpMyAdmin stay isolated on ghost-internal.
  • NPM → ghost-nginx:2368 (within edge) → Ghost (private) → MySQL (private). A clean, layered boundary.

.env (Edit to your environment):

# ── General ───────────────────────────────
TZ=America/New_York            # Change to your timezone (e.g., Asia/Seoul)

# ── MySQL ─────────────────────────────────
MYSQL_ROOT_PASSWORD=change_me_root_pass   
MYSQL_DATABASE=ghost                      # Default DB name for Ghost
MYSQL_USER=ghost                          # Ghost DB user (can rename if desired)
MYSQL_PASSWORD=change_me_ghost_pass       

# ── Ghost ─────────────────────────────────
GHOST_URL=https://ghost.wody.duckdns.org  

# ── phpMyAdmin ───────────────────────────
PMA_ABSOLUTE_URI=https://ghost.wody.duckdns.org/pma/   
# Must match Ghost domain + /pma/

Notes:

  • The above example uses DuckDNS (wody.duckdns.org) as a sample domain.
  • If you own a custom domain (e.g., mydomain.com), replace it everywhere with your domain.
  • Always replace placeholder passwords (change_me_*) with secure, strong, unique values.

Step 3: Hardened Nginx Configuration

Below is a production-friendly Nginx config adapted for this stack. It proxies Ghost, serves /ads.txt, and frontends phpMyAdmin at /pma/. It also keeps the ActivityPub routes you showed.

config/nginx/nginx.conf:

upstream ghost_upstream {
    server ghost-app:2368;      # compose service name
    keepalive 32;
}

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

    client_max_body_size 50m;
    sendfile on;

    # ── ActivityPub → ap.ghost.org ─────────────────────
    location ^~ /.ghost/activitypub/ {
        proxy_pass https://ap.ghost.org;
        proxy_http_version 1.1;
        proxy_ssl_server_name on;

        proxy_set_header Host              ghost.yourname.duckdns.org;    # <- Modify this!
        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;
        proxy_set_header Authorization     $http_authorization;

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

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

        proxy_set_header Host              ghost.yourname.duckdns.org;    # <- Modify this!
        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;
    }

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

        proxy_set_header Host              ghost.yourname.duckdns.org;    # <- Modify this!
        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;
    }
    # ────────────────────────────────────────────────────

    # phpMyAdmin: /pma → /pma/
    location = /pma { return 301 https://$host/pma/; }

    # phpMyAdmin under subpath
    location ^~ /pma/ {
        client_max_body_size 512m;

        proxy_pass http://ghost-pma:80/;   # trailing slash is important
        proxy_set_header X-Forwarded-Prefix /pma;

        proxy_set_header Host               ghost.yourname.duckdns.org;    # <- Modify this!
        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  https;
        proxy_set_header X-Forwarded-Host   $host;

        proxy_redirect off;
    }

    # Static cache (place after /pma/ block)
    location ~* \.(css|js|ico|svg|ttf|otf|woff|woff2)$ {
        expires 30d;
        access_log off;
        proxy_pass http://ghost_upstream;
        proxy_http_version 1.1;
    }

    # ads.txt
    location = /ads.txt {
        alias /srv/public/ads.txt;
        default_type text/plain;
        add_header Cache-Control "public, max-age=3600";
    }

    # Everything else → Ghost
    location / {
        client_max_body_size 5g;

        proxy_pass http://ghost_upstream;
        proxy_http_version 1.1;

        # If you terminate SSL at NPM, keep these forwarded headers:
        proxy_set_header Host               $host;
        proxy_set_header X-Forwarded-Proto  https;
        proxy_set_header X-Forwarded-Host   $host;
        proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;

        # WebSocket/HTTP2 upgrade (if needed):
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    access_log /dev/stdout;
    error_log  /dev/stderr warn;
}
Replace the value of proxy_set_header Host ghost.yourname.duckdns.org to yours.

Why those ActivityPub routes (and headers) matter

If you enable Ghost’s Network features in Ghost Admin (the Fediverse/ActivityPub integration), your blog participates in decentralized social features: following, sharing, discovery, and federation. Under the hood, Ghost relies on ap.ghost.org for parts of this functionality and expects a reverse proxy that passes through specific endpoints and headers intact.

That’s why the config above includes:

  • /.ghost/activitypub/… → proxied to https://ap.ghost.org
  • /.well-known/webfinger → proxied to https://ap.ghost.org
  • /.well-known/nodeinfo → proxied to https://ap.ghost.org

These three routes are how other servers and clients discover your profile and content. Small proxy mistakes here will make federation silently fail.

Headers you must keep (don’t “simplify” these)

  • proxy_set_header Host ghost.yourname.duckdns.org;
  • 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;
  • proxy_set_header Authorization $http_authorization; (on the /.ghost/activitypub/ block)

These tell the upstream exactly which host and scheme the request came in on (so links and signatures are correct), and pass through the Authorization header when needed. Removing or rewriting them often breaks discovery or authenticated calls.

Also keep:

  • proxy_http_version 1.1;
  • proxy_ssl_server_name on; (so SNI works with ap.ghost.org)

Very sensitive: don’t cache or alter these endpoints

  • Do not cache /.ghost/activitypub/, /.well-known/webfinger, or /.well-known/nodeinfo.
  • Do not strip/override Authorization.
  • Do not force http in forwarded headers if your site is actually https.

If you’re using Nginx Proxy Manager (NPM) in front, do not add global custom headers that conflict with these per-location headers. When in doubt, keep your general security headers at NPM, and keep these forwarding headers in Nginx exactly as shown.

Common “it’s dead” symptoms (and what to check)

  • Webfinger fails: remote servers can’t find @you@yourdomain
    • Check /.well-known/webfinger returns 200 and JSON (not HTML).
    • Make sure X-Forwarded-Proto is https and Host is your domain.
  • Nodeinfo is missing: federation status UIs show “unknown”
    • /.well-known/nodeinfo should return JSON with a link to your nodeinfo URL.
  • ActivityPub actions error or time out
    • Ensure /.ghost/activitypub/… passes Authorization and uses HTTP/1.1.

Quick curl tests

Run these from outside your network (or via a tool like ReqBin) with your real domain:

# WebFinger
curl -i https://yourdomain.tld/.well-known/webfinger?resource=acct:you@yourdomain.tld

# Nodeinfo
curl -i https://yourdomain.tld/.well-known/nodeinfo

# ActivityPub discovery (example; exact paths vary by Ghost)
curl -I https://yourdomain.tld/.ghost/activitypub/.well-known/host-meta

You should see 200/302 responses with JSON (for the first two) — not your homepage HTML.


Bottom line: the Network tab features are powerful for discovery and distribution, but they’re extremely header-sensitive behind a reverse proxy. Keep the per-location blocks exactly as shown, don’t cache or strip headers on those paths, and verify with curl whenever you change Nginx/NPM settings.


Step 4: Ghost Configuration (SMTP + Login Settings)

Ghost requires SMTP for sending staff invites, password resets, and member notifications. You can configure it with any SMTP provider:

  • ✅ Commercial services like Gmail, SendGrid, Mailgun, Amazon SES, Outlook, Zoho
  • ✅ A self-hosted mail server (Postfix, Exim, etc.)

⚠️ Caution: Running your own mail server at home is usually not recommended. Without perfect DNS (SPF, DKIM, DMARC) and a trusted IP reputation, your messages will likely land in spam. For reliability, use a third-party SMTP service.


config.production.json: (Gmail with App Password)

{
  "security": {
    "staffDeviceVerification": true
  },
  "mail": {
    "transport": "SMTP",
    "options": {
      "service": "Gmail",
      "auth": {
        "user": "your_gmail_account@example.com",      // your Gmail address
        "pass": "your_16_digit_app_password"           // generated App Password
      }
    }
  }
}

Staff Device Verification (Login Security)

By default, Ghost enables staff device verification (true).

  • Each time you log into Ghost Admin from a new location, Ghost sends you a 6-digit code via email.
  • This is an extra security layer to prevent unauthorized logins.

👉 In my own setup, I disabled it (false) because I often move between devices and networks while writing. If you also find the verification flow too cumbersome, you can safely disable it:

"security": {
  "staffDeviceVerification": false
}

This change lets you log in with just your username + password, skipping the email verification step.


Gmail App Password Setup

To use Gmail, you must generate an App Password (your normal Google password won’t work).

Account settings: Your browser is not supported.
  1. Enable 2-Step Verification
  2. Generate App Password
    • Go to App Passwords.
    • Select:
      • App: Mail
      • Device: Other → enter Ghost
    • Click Generate.
  3. Copy the 16-digit password
    • Paste it into the "pass" field in your Ghost config (no spaces).

Why App Passwords Matter

  • Google blocks regular passwords for SMTP.
  • Without an App Password, Ghost email features won’t work.
  • You can revoke App Passwords anytime without affecting your main login.
  • Better to store it in somewhere secure (Unable to read again after you close it. If you and to use it again). But if you don't remember, you can make new passwords as much as you want.

👉 After editing the config, restart Ghost:

docker compose restart ghost

Then test it by sending a staff invite from Ghost Admin.


Step 5: phpMyAdmin Configuration

Every once in a while, you may find yourself needing to manually edit the Ghost database.

For me, one such case was when I changed the domain name of an already running Ghost blog. Although I updated the domain inside Ghost’s admin settings, all the old posts still referenced images with the previous domain name. As a result, every image on my blog broke.

The fix? I quickly jumped into the MySQL container, opened the database, and replaced all occurrences of the old domain with the new one. It was like running “Find & Replace” in a text editor—but in SQL.

👉 Of course, this kind of manual operation is rare, but it showed me how useful a GUI can be when emergencies happen. That’s why I decided to always include phpMyAdmin in my Ghost stack. With it, you can browse tables, edit entries, and run queries without memorizing SQL commands. It’s especially handy for quick fixes like domain changes, user management, or backups.

💡 Side Note (Not Essential, Just an Anecdote):
If you ever need to perform a bulk replacement in MySQL, you can generate UPDATE statements across all text-based columns using a query like this:

SELECT CONCAT('UPDATE `', table_name, '` SET `', column_name, '` = REPLACE(`', column_name, '`, "olddomain.com", "newdomain.com");')
FROM information_schema.columns
WHERE table_schema = 'ghost'
  AND data_type IN ('char','varchar','text','mediumtext','longtext');

This was the exact trick I used to fix my broken images. Again—it’s not something you’ll likely need often, but it’s a neat piece of SQL to keep in your toolbox.


👉 With that context in mind, let’s move on to actually configuring phpMyAdmin in Docker so you always have a safety net for database management.

config/pma/config.user.inc.php:

<?php
$cfg['blowfish_secret'] = 'replace_with_32_char_random_secret';

$i = 0;
$i++;
$cfg['Servers'][$i]['host'] = 'mysql';
$cfg['Servers'][$i]['port'] = '3306';
$cfg['Servers'][$i]['user'] = 'root';
$cfg['Servers'][$i]['auth_type'] = 'cookie';

$cfg['LoginCookieRecall'] = true;
$cfg['LoginCookieValidity'] = 86400;
$cfg['LoginCookieStore'] = 0;

$cfg['UploadDir'] = '/var/www/html/tmp';
$cfg['SaveDir']   = '/var/www/html/tmp';

$cfg['DefaultLang'] = 'en';
$cfg['ThemeDefault'] = 'pmahomme';

$cfg['QueryHistoryDB'] = false;
$cfg['QueryHistoryMax'] = 100; 

⚠️ Note on config.user.inc.php
You might notice that the file does not end with ?>. This is intentional.

In PHP projects, the closing tag is often omitted to prevent accidental whitespace or newlines from being sent to the output buffer. That tiny whitespace could break headers or cause unexpected issues.

👉 So don’t worry—your config.user.inc.php should stay exactly as it is, without a closing tag.

👉 It's no problem with pma-php.ini file is empty. But must be exist.


Step 6: 🚀 Bringing It All Online

At this point, you’ve written your docker-compose.yml, prepared the config files, and mounted everything in place. Now it’s time to bring your Ghost blog online.


1. Deploy the Stack

# Launch the stack
docker compose up -d

This will start all four containers: MySQL, Ghost, Nginx, and phpMyAdmin.


2. Verify Containers Are Running

Check that everything is up and healthy:

docker ps

You should see all services listed with Up status.

Follow the logs for Nginx (the entry point of your stack):

docker logs -f ghost-nginx

If there are no obvious errors, Nginx is ready to serve traffic.


3. Configure Nginx Proxy Manager (NPM)

Because we didn’t expose any external ports in the docker-compose.yml, you won’t be able to access Ghost or phpMyAdmin directly by IP and port. Instead, everything must go through Nginx Proxy Manager (NPM) on the shared edge network.

In NPM:

  • Go to Hosts → Proxy Hosts → Add Proxy Host
  • Enter your domain:
    • ghost.yourname.duckdns.org (if you’re using DuckDNS)
    • ghost.yourdomain.com (if you own a personal domain)
  • Forward Hostname/IP: ghost-nginx
  • Forward Port: 2368
  • Scheme: http
  • Under SSL:
    • Request a new Let’s Encrypt certificate
    • Enable Force SSL and HTTP/2
    • Leave HSTS off for now

Repeat the process for phpMyAdmin at:

  • Domain: ghost.yourname.duckdns.org/pma (or ghost.yourdomain.com/pma)
  • Forward Hostname/IP: ghost-nginx
  • Forward Port: 2368
  • Scheme: http
  • (Nginx internally maps /pma/ to phpMyAdmin)

4. First Launch: Database Initialization

Once NPM is configured, visit your blog URL:

👉 https://ghost.yourname.duckdns.org

On first run, you’ll likely see a “Database installing / Blog preparing…” type of page. Don’t panic—this is normal.

⚠️ MySQL initialization takes time:

  • On a powerful PC: a few minutes
  • On slower hardware like Synology NAS: up to 20 minutes or more

If you’re impatient, open phpMyAdmin (https://ghost.yourdomain.com/pma/) and check the Ghost database. You’ll see tables gradually being created. As long as the database is filling up, the process is still running fine.

This double-check not only reassures you, but also confirms phpMyAdmin is working correctly.


5. Accessing Ghost Admin

Once the database is ready, Ghost will show its default homepage. To access the admin dashboard, go to:

👉 https://ghost.yourdomain.com/ghost

The first-time setup wizard will ask you to:

  • Create an admin account
  • Configure basic site settings
  • Optionally run through the built-in tutorial (or skip it)

If the Network tab inside Ghost Admin works for you—congratulations! You’re lucky. If not, don’t worry; the blog works perfectly fine without it.


6. Next Steps

From here, you can:

  • Explore Ghost’s Settings (bottom-left gear icon in the admin panel)
  • Update your domain information
  • Choose one of the official Ghost themes or upload your own custom theme
  • Customize the header, footer, and theme layout via Code Injection
  • Fine-tune SEO, integrations, and email settings
  • …and much more. (This is where the real journey—and sometimes the real pain—begins!)

👉 But be careful: these customizations go beyond the scope of this installation guide. We’ll cover them in a future post.


✅ At this stage, you should now have:

  • A fully deployed Ghost blog accessible via your domain
  • A working phpMyAdmin panel for database management
  • NPM handling SSL and reverse proxy, keeping everything clean and secure

7. Troubleshooting

Running Ghost in Docker is powerful, but like any self-hosted stack, you’ll run into hiccups. Below are some of the most common errors you may encounter—and how to deal with them without pulling your hair out.


1. MySQL “unhealthy” Error

You may see your Ghost container stuck waiting for MySQL because healthcheck keeps failing.
This usually happens during the very first deployment—MySQL can take a long time to initialize, especially on lower-end systems like a Synology NAS (sometimes over 10 minutes).

In the docker-compose.yml, we added a generous start period:

healthcheck:
  test: ["CMD-SHELL", "mysql -h127.0.0.1 -uroot -p\"$$MYSQL_ROOT_PASSWORD\" -e 'SELECT 1' >/dev/null 2>&1 || exit 1"]
  start_period: 3m     # increase this on slower hardware
  interval: 5s
  timeout: 3s
  retries: 60

👉 If you’re on slow hardware, increase start_period to 5–10 minutes.
👉 Once MySQL has finished its first boot, you can safely reduce or remove this value for faster container restarts.


2. Ghost Admin “Oops!” or Site not configured correctly

Sometimes, when exploring the Network tab in Ghost Admin, you’ll be greeted with an Oops! error, or the dreaded Site not configured correctly.

Here’s the honest truth:

  • This network feature is not essential for running a blog.
  • Even with identical configs, Ghost may behave differently across servers.
  • If it doesn’t work, the easiest solution is to turn off network features in the Ghost Admin settings and move on.

👉 Don’t waste days chasing this ghost (pun intended). Like me, you’ll only end up frustrated. When (or if) I finally figure out the real cause, I’ll share it in a dedicated troubleshooting post.


3. phpMyAdmin Session or Login Errors

  • If phpMyAdmin doesn’t keep you logged in, check the config.user.inc.php for cookie settings.
  • If uploads fail, increase the UPLOAD_LIMIT environment variable and make sure the pma-php.ini is mounted correctly.

4. General Container Failures

  • Ghost not starting? → Check the config.production.json for typos (especially around url or mail settings).
  • Nginx 502 Bad Gateway? → Likely your Ghost app isn’t healthy yet. Wait a minute or two, or verify ports and networks.
  • SSL certificate errors? → Confirm your domain (DuckDNS or custom) resolves correctly to your public IP before requesting a Let’s Encrypt cert.

5. Ghost Content Folder Not Mounting Properly

One issue that occasionally frustrates Ghost users is with the content/ folder volume mount. Inside the Ghost container, the content/ directory isn’t a plain folder—it’s a symlink (symbolic link) to another path.

When you mount a host directory directly to this path, Docker sometimes “breaks” the symlink. The result:

  • Multiple empty content folders may be created.
  • The link inside the container becomes disconnected.
  • Ghost starts up, but your themes, images, and posts appear missing.

⚠️ The worst part? Trying to “fix” it after the fact can be painful. You often end up juggling broken mounts, cleaning up dangling symlinks, and moving files around manually.

👉 For now, just be aware of this caveat. In a future post, I’ll cover best practices for managing Ghost’s content/ directory in a way that avoids these headaches.

👉 With these troubleshooting notes in hand, you’ll have the confidence to fix common issues quickly—or at least know when to stop stressing over non-critical bugs.


6. The Bigger Picture

Ghost alone has so many quirks and edge cases that you could start an entire blog dedicated just to error fixes. (Hmm… maybe I actually should? 😅)

For now, this guide will stop at ensuring:
✅ Containers deploy successfully
✅ Ghost is reachable via domain/subdomain
✅ phpMyAdmin is accessible for database management

That’s enough to get your blog running in production without drowning in rabbit holes.


Final Thoughts

With this setup, you now have:

  • Ghost served behind a hardened Nginx proxy
  • MySQL 8 for database reliability
  • phpMyAdmin for easy DB management
  • SSL and reverse proxying handled by NPM
  • Production-ready headers and caching policies

This is no longer a “toy” blog—it’s an environment that can run in production on your WSL2 + Docker home server with the same patterns you’d use on a cloud VPS.


Next Up in Part 6

In the next part, we’ll shift gears and explore what happens when you run your home server on a desktop-class machine with elevated privileges. Specifically, we’ll install Jellyfin, a powerful open-source media server.

Earlier in this series, I briefly mentioned that my own server runs on an older—but still capable—gaming PC. Over time, I’ve upgraded it a bit, and now it’s equipped with an NVIDIA RTX 3080 Ti (yes, that beast!!). That makes it the perfect candidate to demonstrate GPU-accelerated transcoding in Jellyfin.

👉 Stay tuned for Part 6: Running Jellyfin with NVIDIA GPU Transcoding on Docker—a real showcase of how to squeeze maximum performance out of your home server setup