Step-by-step Guide Self-Hosting Ghost

Step-by-step Guide Self-Hosting Ghost
Photo by Dayne Topkin / Unsplash

This category launches with Ghost self-hosting guide — because real data scientists build their own infrastructure.

Ghost is perfect for data science writing with its clean design, Markdown support, and built-in newsletter.

This guide uses Ghost, Docker Compose, Nginx and a VPS (virtual private server) with Ubuntu.

Ghost is the writing platform, Nginx is the production web server. Together they are unbeatable for self-hosted blogs.

Prerequisites

  1. Domain name (e.g. your.blog) pointing to your VPS IP (A record)
  2. VPS with Ubuntu (e.g. 24.04+) 2vCPU, 2GB RAM+, 25GB+ NVMe
  3. SSH access as rootor sudo user

Step 1: Create VPS

  1. Choose a VPS hosting offer (e.g. Hetzner, Spaceship, etc.) and pick a plan
  2. A good starter plan is 2vCPU, 2GB RAM+, 25GB+ NVMe
  3. Select Ubuntu LTS as OS
  4. Deploy and get IP, root password, SSH
  5. Connect from your terminal via SSH ssh root@your-vps-ip
  6. Update your system sudo apt update && sudo apt upgrade -y

Step 2: Install Docker & Docker Compose

Why Docker for Ghost? The Real Benefit

  1. Exact environment guarantee
  2. No MySQL dependency hell, since docker-compose.ymlpins every version. Dev, staging, production have identical behaviour.
  3. Disaster recovery in 60s. No manual reinstall. The docker-compose.yml + volumes can give instant recovery
  4. Zero-dependency host. The VPS stays clean
  5. docker-compose.yml is the infrastructure code. It can be committed to Git, which makes it shareable, reproducible, upgradeable.
# 1 install Docker
apt install docker.io docker-compose -y

# 2 enable
systemctl enable --now docker

# logout/login if non-root
usermod -aG docker $USER

# test
docker run hello-world

Step 3: Set up persistent directories & Nginx reverse proxy

Why Nginx for Ghost?

It solves performance, security, and flexibility issues.

  1. Lightning-fast static assets. Ghost serves images / CSS / JS through Node.js, but it is slow. Nginx caches them directly.
    1. Result: images load in 25ms, not in 450ms (Node.js)
  2. A+ security layer. Nginx terminates SSL, adds security headers Ghost cannot. Node.js cannot do this natively. Nginx is battle-tested for web security.
  3. HTTP/2 + compression. Ghost on port 2368 stays HTTP/1.1. Nginx upgrades to modern protocols.
  4. Rate limiting + DDoS protection. It protects your VPS from bots / abuse. Ghost has zero built-in protection.
  5. Zero-downtime upgrades. No 503 error during deploys. Nginx buffers requests while Ghost restarts.
# install Nginx
apt install nginx certbot python3-certbot-nginx -y

# setup directories
mkdir -p /var/lib/ghost/content /var/lib/mysql

# Ghost / MySQL users
chown -R 65534:65534 /var/lib/ghost /var/lib/mysql

Step 4: Create docker-compose.yml

Create a folder at /rootand name it ghostvia the command mkdir ghost. Use either nanoor vito create the docker-compose.ymlfile. Enter in the terminal nano /root/ghost/docker-compose.yml

services:
  ghost:
    image: ghost:6-alpine
    volumes:
      - ghost_content:/var/lib/ghost/content
    environment:
      url: https://<your.blog>  # ← Plain text only
      database__client: mysql
      database__connection__host: db
      database__connection__user: ghost
      database__connection__password: <password>
      database__connection__database: ghost
    ports:
      - "127.0.0.1:2368:2368"  # Bind localhost only (secure)
    depends_on:
      db:
        condition: service_healthy    # ← WAITS for MySQL ready!

  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: <password>
      MYSQL_DATABASE: ghost
      MYSQL_USER: ghost
      MYSQL_PASSWORD: <password>
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p7AvE2DBCh5JD"]
      timeout: 20s
      retries: 10

volumes:
  ghost_content:
  mysql_data:

Important: replace <your.blog> with your actual domain and generate strong passwords

Step 5: Start Ghost

If you are not in the ~/ghostdirectory, then go first to your Ghost folder, where your docker-compose.ymlfile is. If you followed along it is in /root/ghost/

# 1 got to Ghost folder
cd /ghost

# 2 pull & start in background
docker compose pull && docker compose up -d

To see if everything goes smoothly you can open a second terminal and monitor the log files via docker compose logs -f ghost

Step 6: Configure Nginx & SSL

Until step 5, everything should be easy-going. But from here on configuring Nginx and making SSL work can be very troublesome. Please expect some difficulties, even if you follow along exactly it will be more technical now.

Create Config

First, you must create an Nginx config file at /etc/nginx/sites-available/. I named it after Ghost. So the command is nano /etc/nginx/sites-available/ghost

# HTTP → HTTPS redirect
server {
    listen 80;
    listen [::]:80;
    server_name <your.blog> www.<your.blog>;
    return 301 https://$host$request_uri;
}

# HTTPS main - A+ SECURITY
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name <your.blog> www.<your.blog>;

    # SSL
    ssl_certificate /etc/letsencrypt/live/<your.blog>/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/<your.blog>/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # ALL SECURITY HEADERS (single instances, Ghost-safe CSP)
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), fullscreen=(self)" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data: https:; connect-src 'self' https: wss:;" always;

    # Content images
    location ^~ /content/ {
        proxy_pass http://127.0.0.1:2368;
        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_cache off;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # Main Ghost proxy
    location / {
        proxy_pass http://127.0.0.1:2368;
        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_set_header X-Forwarded-Host $host;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_cache_bypass $http_upgrade;
        proxy_buffering on;
        proxy_buffer_size 128k;
        proxy_buffers 4 256k;
        proxy_busy_buffers_size 256k;
        client_max_body_size 50M;
    }

    # Static assets
    location ~* \.(png|jpg|jpeg|gif|webp|ico|svg)$ {
        proxy_pass http://127.0.0.1:2368;
        proxy_cache off;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }
    
    # ActivityPub
    location ~ ^/\\.ghost/activitypub/ {
        proxy_pass https://ap.ghost.org;
        proxy_set_header Host ap.ghost.org;
        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_ssl_server_name on;
    }

    # WebFinger/NodeInfo
    location ~ ^/\\.well-known/(webfinger|nodeinfo) {
        proxy_pass https://ap.ghost.org;
        proxy_set_header Host ap.ghost.org;
        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_ssl_server_name on;
    }
}

Enable Config

Nginx site setup. The 4-Command Ritual

  1. Step 1 activates your ghost config (A+ security, reverse proxy magic)
  2. Step 2 prevents "multiple default server" errors
  3. Step 3 catches typos before breaking your live site
  4. Step 4 applies changes safely
# 1 enable Ghost config
ln -s /etc/nginx/sites-available/ghost /etc/nginx/sites-enabled/

# 2 remove default site
rm /etc/nginx/sites-enabled/default

# 3 test everything works
nginx -t

# 4 restart with zero downtime
systemctl restart nginx

Pro tip: Run nginx -t obsessively. One missing semicolon = 503 errors.

SSL (Free Let's Encrypt)

sudo certbot --nginx -d <your.domain> -d www.<your.domain> --email <your@email> --agree-tos --no-eff-email

Step 7: Complete Ghost Setup

  1. Visit https://<your.domain>/ghost. You are prompted to create an admin account with your name, email, and password
  2. Go to Ghost General Settings → Navigation and set up your blog primary & secondary navigation. Primary navigation appears in the menu and reflects your blog categories, while secondary navigation is in the footer, such as Contact, Terms of Service, Privacy Policy, About, and Sign up.
  3. In General Settings → Design & Branding choose a theme you like and add your icon (512x512 recommended) and logo (1200x400+ recommended). Casper is a default minimalistic design. Optional, you can buy a premium theme from ghost.org/themes.
  4. Go to Pages and create the pages of your secondary navigation and link it accordingly.

Step 8: Post-Setup Essentials

  1. Firewall: do a production hardening of your Ghost. Secure your blog by protecting SSH access, opening HTTPS (Nginx), and blocking Ghost port 2368 externally.
    1. Allow SSH first. This is critical
      1. sudo ufw allow ssh
      2. sudo ufw allow 22/tcp
    2. Allow web ports
      1. sudo ufw allow 80/tcp
      2. sudo ufw allow 443/tcp
    3. Block Ghost direct access. Why blocking 2368/tcp? It is security best practice to block direct access to Ghost's internal port (2368) so attackers can't bypass Nginx.
      1. sudo ufw deny 2368/tcp
      2. External: https://<your.domain> Nginx localhost:2368 (safe)
      3. external: http://<your_domain_ip>:2368 Ghost directly (vulnerable!)
        1. It bypasses Nginx security headers, has no SSL enforcement, exposes login attempts, and makes brute force easier.
    4. Enable firewall. It is safe now to do so, if you followed steps a - c.
      1. sudo ufw --force enable
    5. Check firewall status
      1. check the status via sudo ufw status numbered it will give you a list which ports are allowed and denied
    6. Status check
      1. Test direct access, it must be BLOCKED. The curl command curl -I http://<your_domain_ip> should fail and timeout
      2. Test if proxy works by curl -I https://<your.domain> . It should return a 200 OK.
  2. Email for newletters: in Ghost Admin → Settings → General you can add Mailgun for sending out newsletters
  3. Backup via Cron: decide how often you want to do a backup and create a cron job. The following command creates a daily backup with a timestamp crontab -e 0 2 * * * docker compose down && tar czf /backups/ghost-$(date +%Y%m%d).tar.gz /var/lib/ghost /var/lib/mysql && docker compose up -d
  4. Update your docker compose with docker compose pull && docker compose up -d. Do it weekly!

Your blog is now live, secure, and production-ready. Test by visiting your domain.

Step 9: Create First Post

  1. Go to Ghost Admin → Posts → New Post
  2. Write title and body. Add tags (e.g. #DataScience), featured image.
  3. You can check by using the preview function.
  4. Once you are done click on Publish → Done! Your post is live at https://<your.domain>/<your-post-slug>/