Step-by-step Guide Self-Hosting Ghost
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
- Domain name (e.g. your.blog) pointing to your VPS IP (A record)
- VPS with Ubuntu (e.g. 24.04+) 2vCPU, 2GB RAM+, 25GB+ NVMe
- SSH access as
rootorsudo user
Step 1: Create VPS
- Choose a VPS hosting offer (e.g. Hetzner, Spaceship, etc.) and pick a plan
- A good starter plan is 2vCPU, 2GB RAM+, 25GB+ NVMe
- Select Ubuntu LTS as OS
- Deploy and get IP, root password, SSH
- Connect from your terminal via SSH
ssh root@your-vps-ip - Update your system
sudo apt update && sudo apt upgrade -y
Step 2: Install Docker & Docker Compose
Why Docker for Ghost? The Real Benefit
- Exact environment guarantee
- No MySQL dependency hell, since
docker-compose.ymlpins every version. Dev, staging, production have identical behaviour. - Disaster recovery in 60s. No manual reinstall. The
docker-compose.yml+ volumes can give instant recovery - Zero-dependency host. The VPS stays clean
docker-compose.ymlis 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-worldStep 3: Set up persistent directories & Nginx reverse proxy
Why Nginx for Ghost?
It solves performance, security, and flexibility issues.
- Lightning-fast static assets. Ghost serves images / CSS / JS through Node.js, but it is slow. Nginx caches them directly.
- Result: images load in 25ms, not in 450ms (Node.js)
- A+ security layer. Nginx terminates SSL, adds security headers Ghost cannot. Node.js cannot do this natively. Nginx is battle-tested for web security.
- HTTP/2 + compression. Ghost on port 2368 stays HTTP/1.1. Nginx upgrades to modern protocols.
- Rate limiting + DDoS protection. It protects your VPS from bots / abuse. Ghost has zero built-in protection.
- 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/mysqlStep 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 -dTo 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
- Step 1 activates your
ghostconfig (A+ security, reverse proxy magic) - Step 2 prevents "multiple default server" errors
- Step 3 catches typos before breaking your live site
- 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 nginxPro 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-emailStep 7: Complete Ghost Setup
- Visit
https://<your.domain>/ghost. You are prompted to create an admin account with your name, email, and password - 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.
- 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.
- Go to Pages and create the pages of your secondary navigation and link it accordingly.
Step 8: Post-Setup Essentials
- Firewall: do a production hardening of your Ghost. Secure your blog by protecting SSH access, opening HTTPS (Nginx), and blocking Ghost port 2368 externally.
- Allow SSH first. This is critical
sudo ufw allow sshsudo ufw allow 22/tcp
- Allow web ports
sudo ufw allow 80/tcp-
sudo ufw allow 443/tcp
- 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.
sudo ufw deny 2368/tcp- External: https://<your.domain> → Nginx → localhost:2368 (safe)
- external: http://<your_domain_ip>:2368 → Ghost directly (vulnerable!)
- It bypasses Nginx security headers, has no SSL enforcement, exposes login attempts, and makes brute force easier.
- Enable firewall. It is safe now to do so, if you followed steps a - c.
sudo ufw --force enable
- Check firewall status
- check the status via
sudo ufw status numberedit will give you a list which ports are allowed and denied
- check the status via
- Status check
- Test direct access, it must be BLOCKED. The curl command
curl -I http://<your_domain_ip>should fail and timeout - Test if proxy works by
curl -I https://<your.domain>. It should return a 200 OK.
- Test direct access, it must be BLOCKED. The curl command
- Allow SSH first. This is critical
- Email for newletters: in Ghost Admin → Settings → General you can add Mailgun for sending out newsletters
- 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 - 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
- Go to Ghost Admin → Posts → New Post
- Write title and body. Add tags (e.g. #DataScience), featured image.
- You can check by using the preview function.
- Once you are done click on Publish → Done! Your post is live at https://<your.domain>/<your-post-slug>/