I put Ghost on a VPS and Nginx returned 502 until port 2368 was actually up

I put Ghost on a VPS and Nginx returned 502 until port 2368 was actually up

I wanted a publishing stack I control — posts, members, newsletters — not another hosted CMS. Ghost on Ubuntu 24.04 with Docker or Ghost-CLI is fine until Nginx 502s because nothing is listening on 2368, or Ghost-CLI refuses to install under /root. Here's that wall.

· Updated · 3 min read #self-hosted #open-source #ghost #blog #nodejs #publishing #deployment #docker #vps

 Ghost self-hosted publishing platform

Caption: Ghost on my VPS — articles, members, and newsletters without handing the archive to a hosted CMS.

Introduction

I wanted a writing stack I operate: posts, newsletters, members, a theme I can break without waiting on a SaaS dashboard. Ghost is Node.js + MySQL, admin at /ghost. It is not WordPress with extra plugins. That is why I picked it.

On Ubuntu 24.04 I labbed two paths. Docker Compose: official ghost:6-alpine plus MySQL 8, bound to 127.0.0.1:2368, Nginx and Certbot in front. Ghost-CLI: Nginx, MySQL 8, Node.js 22, systemd — the installer wires SSL if I answer the prompts honestly.

The wall I hit on the Docker path is the one this install is famous for: Nginx 502 Bad Gateway because Ghost is not up yet, or Nginx proxies to the wrong local port. On Ghost-CLI, the installer fails permission checks if I run it as root or put the site under /root. I moved to a normal sudo user and /var/www/ghost, then ghost install finished.

I set url to the final https:// hostname before I invite staff. Localhost mail is not how I send newsletters.

Why I picked Ghost

  • Posts, pages, members, tags, themes — not a general-purpose CMS I have to tame.
  • Open-source core; content lives on my disk and MySQL.
  • Fast enough on a modest VPS when MySQL is local.
  • Members and newsletters without a plugin zoo.
  • Handlebars themes I can version in git.
  • Docker or Ghost-CLI, depending on how I want to operate the box.

I use it for a publication I am willing to patch. If I wanted zero ops, I would pay Ghost(Pro). I wanted the server.

Prerequisites

Hardware:

  • 1 CPU / 1 GB RAM for a small site
  • 2 CPU / 2–4 GB RAM if I send newsletters and host a lot of images
  • 20 GB free to start, plus room for images, themes, DB, backups
  • SSD for MySQL

Software and accounts:

  • Ubuntu 24.04 LTS with sudo
  • Domain such as ghost.example.com
  • DNS A or AAAA
  • SMTP for staff, members, and newsletters
  • Docker + Compose for the container path
  • Nginx, MySQL 8, Node.js 22 LTS, Ghost-CLI for the manual path

Security notes:

  • SSH keys; password SSH off.
  • 80/443 open; MySQL not on the public internet.
  • Ghost url = final HTTPS URL before users exist.
  • Backup content directory and MySQL before major upgrades.
  • Real SMTP provider.
sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg git ufw

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

Installation Guide

I pick one method per server unless I am running two Ghosts on different domains.

1. Docker Compose Method: Create the Project Directory

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker
docker --version
docker compose version
sudo mkdir -p /opt/ghost
sudo chown "$USER":"$USER" /opt/ghost
cd /opt/ghost

2. Docker Compose Method: Define Ghost and MySQL

The Ghost image maps nested config with double underscores.

cat > compose.yaml <<'EOF'
services:
  ghost:
    image: ghost:6-alpine
    restart: unless-stopped
    depends_on:
      - db
    ports:
      - "127.0.0.1:2368:2368"
    environment:
      url: https://ghost.example.com
      database__client: mysql
      database__connection__host: db
      database__connection__user: ghost
      database__connection__password: change-this-ghost-db-password
      database__connection__database: ghost
      mail__transport: SMTP
      mail__options__host: smtp.example.com
      mail__options__port: 587
      mail__options__secure: "false"
      mail__options__auth__user: ghost@example.com
      mail__options__auth__pass: change-this-smtp-password
    volumes:
      - ghost-content:/var/lib/ghost/content

  db:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: change-this-root-password
      MYSQL_DATABASE: ghost
      MYSQL_USER: ghost
      MYSQL_PASSWORD: change-this-ghost-db-password
    volumes:
      - ghost-db:/var/lib/mysql

volumes:
  ghost-content:
  ghost-db:
EOF

Same MySQL password on both services. Real domain and passwords before up.

 Ghost Docker stack architecture

Caption: Ghost and MySQL in Compose; Nginx or Caddy owns HTTPS. Port 2368 stays on localhost.

3. Docker Compose Method: Start Ghost

cd /opt/ghost
docker compose pull
docker compose up -d
docker compose ps
docker compose logs -f ghost

Ghost listens on 127.0.0.1:2368. I do not publish 2368 to the world.

4. Docker Compose Method: Add HTTPS with Nginx

sudo apt install -y nginx certbot python3-certbot-nginx

/etc/nginx/sites-available/ghost.example.com:

server {
    listen 80;
    listen [::]:80;
    server_name ghost.example.com;

    location / {
        proxy_set_header Host $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;
        proxy_pass http://127.0.0.1:2368;
    }
}
sudo ln -s /etc/nginx/sites-available/ghost.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d ghost.example.com

Then https://ghost.example.com/ghost for the owner account.

5. Manual Method: Install the Official Stack

sudo apt install -y nginx mysql-server
sudo mysql_secure_installation

Node.js 22 from NodeSource:

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node --version
npm --version
sudo npm install ghost-cli@latest -g
ghost --version
sudo mysql
CREATE DATABASE ghost_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'ghost_prod'@'localhost' IDENTIFIED BY 'change-this-strong-password';
GRANT ALL PRIVILEGES ON ghost_prod.* TO 'ghost_prod'@'localhost';
FLUSH PRIVILEGES;
EXIT;

6. Manual Method: Run Ghost-CLI

Not under /root. Ghost-CLI wants a normal user with sudo.

sudo mkdir -p /var/www/ghost
sudo chown "$USER":"$USER" /var/www/ghost
sudo chmod 775 /var/www/ghost
cd /var/www/ghost
ghost install

Prompts I actually fill:

  • Blog URL: https://ghost.example.com
  • MySQL hostname: localhost
  • MySQL username: ghost_prod
  • MySQL password: the one I created
  • Ghost database name: ghost_prod
  • Set up Nginx: Y
  • Set up SSL: Y
  • Real email for certificate notices
  • Set up systemd: Y
  • Start Ghost: Y
ghost ls
ghost doctor
systemctl status ghost_ghost-example-com

Unit name follows the domain. ghost ls if I forget.

Configuration

Public URL and Admin URL

Docker — url in Compose, then:

cd /opt/ghost
docker compose up -d

Ghost-CLI:

cd /var/www/ghost
ghost config url https://ghost.example.com
ghost restart

A separate admin.url only after both hostnames have DNS and HTTPS.

Email Delivery

Docker: mail__options__* in Compose as above. Ghost-CLI:

cd /var/www/ghost
ghost config mail.transport SMTP
ghost config mail.options.host smtp.example.com
ghost config mail.options.port 587
ghost config mail.options.secure false
ghost config mail.options.auth.user ghost@example.com
ghost config mail.options.auth.pass change-this-smtp-password
ghost restart

I send a test invite. Spam means SPF/DKIM/DMARC on the sending domain, not "Ghost is broken."

Themes and Content

Upload from Settings → Design & branding, or drop themes in content/themes and restart. Docker: ghost-content volume. Ghost-CLI: /var/www/ghost/content. Themes belong in git; I do not edit random files inside the image.

Usage

https://ghost.example.com/ghost

Owner account, then:

  1. Title, description, logo, accent, language.
  2. Staff with the smallest role that works.
  3. Tags for the categories I actually use.
  4. Theme.
  5. SMTP test.
  6. Draft → preview → publish.
  7. Members: tiers, portal, payments if I am charging.

 Ghost editorial workflow

Caption: Draft, review, newsletter, members, public URL — that is the loop I wanted.

Authors write, editors publish, admins touch settings. Owner lives in a password manager.

Screenshots and Visuals

Diagrams are originals. After install I screenshot my admin, members (blurred), newsletter sender, theme, backup output. Not random Ghost shots from search.

Where it broke

On Ubuntu 24.04 this is the failure I see in Ghost's own troubleshooting and in every "Ghost behind Nginx" thread.

1. Nginx 502 Bad Gateway

Browser showed 502. Ghost was not listening on 127.0.0.1:2368, or Nginx pointed at another port.

docker compose ps
docker compose logs -f ghost

Ghost-CLI:

ghost ls
ghost doctor

Nginx proxy_pass must be http://127.0.0.1:2368 for this Compose file. If MySQL is still starting, Ghost exits and Nginx 502s until db is healthy — I wait, then docker compose ps again.

2. Ghost-CLI permission / directory checks

ghost install refused to continue. Official rule: not root, not /root, not a random home folder the CLI flags. I used a sudo user and /var/www/ghost owned by that user (chmod 775). Then the installer ran.

Docker Ghost vs MySQL is the other classic: password mismatch, or database__connection__host set to localhost instead of db inside Compose. The host name db is the service name on the Compose network.

Admin on HTTP or weird redirects: Ghost url is still http:// or a hostname that is not what Certbot issued. I set the final https:// URL and restart.

Troubleshooting

  • Email not delivered: host, port, TLS, user, password, From, DNS. Many providers want app passwords and a verified domain.
  • Image uploads fail: disk full, or permissions on content / the Docker volume.
  • Updates fail: backup first, latest minor before a major, then ghost update or a new image tag.

Scaling, Securing, and Next Steps

Backups

Docker:

cd /opt/ghost
docker compose stop
sudo tar -czf /root/ghost-docker-backup-$(date +%F).tar.gz compose.yaml /var/lib/docker/volumes
docker compose start

Ghost-CLI:

cd /var/www/ghost
ghost stop
mysqldump -u ghost_prod -p ghost_prod > /root/ghost-prod-$(date +%F).sql
sudo tar -czf /root/ghost-content-$(date +%F).tar.gz /var/www/ghost/content
ghost start

I restore on a spare box once. An untested dump is a hopeful file.

Upgrades

Docker:

cd /opt/ghost
docker compose pull
docker compose up -d
docker compose logs --since=10m ghost

Ghost-CLI:

cd /var/www/ghost
ghost backup
ghost update
ghost doctor

Release notes before majors. Official Docker docs want the latest minor before jumping majors.

 Ghost maintenance checklist

Caption: Backups, upgrades, HTTPS, and mail — that is the publication, not just the editor.

Monitoring and hardening

  • Watch https://ghost.example.com and /ghost.
  • Disk, MySQL/container restarts, failed backups.
  • Ubuntu patches.
  • SSH keys only.
  • SMTP, DB, Stripe secrets out of shared notes.
  • Review staff and integrations.
  • Off-site copy of DB + content.

Conclusion

Ghost is running on my Ubuntu 24.04 VPS: either Compose on 127.0.0.1:2368 with Nginx/Certbot, or Ghost-CLI under /var/www/ghost with systemd. Admin is at /ghost, MySQL holds the posts, SMTP is configured before I invite anyone.

Next I would restore a backup once, send a real newsletter to myself, and pin an image tag. The 502 was Ghost not listening; Ghost-CLI was me installing as the wrong user in the wrong directory.

Did you hit the same wall?

I got stuck on Nginx 502 Bad Gateway because nothing was listening on 127.0.0.1:2368. Did you hit the same thing, or a different one — Ghost-CLI permissions under /root, MySQL host localhost inside Compose, mail that never left? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

References

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.