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.
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.phpThis 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(withinedge) → 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 tohttps://ap.ghost.org/.well-known/webfinger→ proxied tohttps://ap.ghost.org/.well-known/nodeinfo→ proxied tohttps://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 withap.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
httpin forwarded headers if your site is actuallyhttps.
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/webfingerreturns200and JSON (not HTML). - Make sure
X-Forwarded-ProtoishttpsandHostis your domain.
- Check
- Nodeinfo is missing: federation status UIs show “unknown”
/.well-known/nodeinfoshould return JSON with a link to your nodeinfo URL.
- ActivityPub actions error or time out
- Ensure
/.ghost/activitypub/…passesAuthorizationand uses HTTP/1.1.
- Ensure
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-metaYou 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).
- Enable 2-Step Verification
- Visit Google Account Security.
- Under “Signing in to Google”, turn on 2-Step Verification.
- Generate App Password
- Go to App Passwords.
- Select:
- App: Mail
- Device: Other → enter
Ghost
- Click Generate.
- Copy the 16-digit password
- Paste it into the
"pass"field in your Ghost config (no spaces).
- Paste it into the
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 ghostThen 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 -dThis will start all four containers: MySQL, Ghost, Nginx, and phpMyAdmin.
2. Verify Containers Are Running
Check that everything is up and healthy:
docker psYou should see all services listed with Up status.
Follow the logs for Nginx (the entry point of your stack):
docker logs -f ghost-nginxIf 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(orghost.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.phpfor cookie settings. - If uploads fail, increase the
UPLOAD_LIMITenvironment variable and make sure thepma-php.iniis mounted correctly.
4. General Container Failures
- Ghost not starting? → Check the
config.production.jsonfor typos (especially aroundurlor 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
contentfolders 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
