I ran a WordPress blog for years. It worked, but the admin interface kept getting more cluttered, plugins kept breaking things, and performance required constant tuning. When I wanted to just write and publish, I found myself fighting the tool instead.
Ghost is the opposite experience. It's built around the writing workflow — clean editor, fast rendering, no plugin complexity. I moved one of my blogs over last year and the difference in day-to-day usability was immediately noticeable.
Self-hosting Ghost means no platform fees, no content restrictions, and complete control over your subscriber list and data. This guide deploys Ghost using Docker Compose on Ubuntu 22.04 — the cleanest way to run it with all dependencies isolated.
I run Ghost on Tencent Cloud Lighthouse. The 2 vCPU / 4 GB RAM plan handles Ghost plus MySQL comfortably. Ghost's managed hosting starts at $9/month for a single publication — self-hosting on Lighthouse at a similar or lower cost gives you multiple publications, full theme customization, and direct access to the database and content files. The snapshot feature is particularly useful for a blog: take a backup before a theme change or major content reorganization, and you can roll back in minutes if something goes wrong.
- Key Takeaways
Ghost(Pro) — the managed hosting — starts at $9/month for basic plans and scales up quickly for larger audiences. Self-hosting Ghost on a $6/month VPS gives you:
The tradeoff: you manage the server, handle updates, and maintain backups.
| Requirement | Notes |
|---|---|
| Cloud server | Tencent Cloud Lighthouse Ubuntu 22.04 |
| A domain name | Required — Ghost needs a URL configured at setup |
| 2 GB+ RAM | Ghost + MySQL needs headroom |
| Docker and Docker Compose | We'll install these |
💡 Faster start: When creating your Lighthouse instance, select Application Image → Docker CE. Docker will already be installed when the server starts — skip the Docker install commands below and start from the Nginx setup.
ssh ubuntu@YOUR_SERVER_IP
sudo apt update && sudo apt upgrade -y
# Install Docker (skip if you chose the Docker CE application image)
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
newgrp docker
sudo apt install -y nginx
sudo ufw allow ssh
sudo ufw allow 'Nginx Full'
sudo ufw enable
mkdir -p ~/apps/ghost && cd ~/apps/ghost
Create docker-compose.yml:
version: '3.8'
services:
ghost:
image: ghost:5-alpine
container_name: ghost
restart: unless-stopped
ports:
- "2368:2368"
environment:
NODE_ENV: production
url: https://yourdomain.com
database__client: mysql
database__connection__host: ghost-db
database__connection__user: ghost
database__connection__password: ${MYSQL_PASSWORD}
database__connection__database: ghost
mail__transport: SMTP
mail__options__host: ${MAIL_HOST}
mail__options__port: 587
mail__options__auth__user: ${MAIL_USER}
mail__options__auth__pass: ${MAIL_PASS}
mail__from: '"Your Blog Name" <noreply@yourdomain.com>'
volumes:
- ghost_content:/var/lib/ghost/content
depends_on:
ghost-db:
condition: service_healthy
ghost-db:
image: mysql:8.0
container_name: ghost-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- ghost_db:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
volumes:
ghost_content:
ghost_db:
Create .env:
nano .env
MYSQL_ROOT_PASSWORD=generate_strong_root_password
MYSQL_PASSWORD=generate_strong_ghost_password
# Email settings (Mailgun example — see Part 6)
MAIL_HOST=smtp.mailgun.org
MAIL_USER=postmaster@mg.yourdomain.com
MAIL_PASS=your_mailgun_smtp_password
chmod 600 .env
# Start Ghost
docker compose up -d
# Watch the startup logs
docker compose logs -f ghost
# Wait until you see: Ghost is running...
sudo nano /etc/nginx/sites-available/ghost
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:2368;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_bypass $http_upgrade;
proxy_read_timeout 86400s;
}
}
sudo ln -s /etc/nginx/sites-available/ghost /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
After HTTPS is configured, update the Ghost URL in docker-compose.yml:
url: https://yourdomain.com
Restart Ghost to apply:
docker compose restart ghost
Visit https://yourdomain.com/ghost — the Ghost admin panel setup wizard will appear.
Your Ghost blog is now live at https://yourdomain.com.
The admin panel is at https://yourdomain.com/ghost.
Ghost needs email for:
Mailgun has a free tier (100 emails/day) that works well for small blogs.
.env file with your credentialsdocker compose restart ghostTest email from the Ghost admin: Settings → Email Newsletter → Send test email.
Ghost themes are zip files. To install a theme:
Or copy directly to the content volume:
# Find where Docker stores the ghost_content volume
docker volume inspect ghost_ghost_content
# Or copy theme directly
docker cp /path/to/theme.zip ghost:/var/lib/ghost/content/themes/
docker exec ghost ghost-theme-install /var/lib/ghost/content/themes/theme.zip
# Export posts as JSON from Ghost admin
# Settings → Labs → Export content → Download
# Or backup the Docker volume directly
docker run --rm \
-v ghost_ghost_content:/data \
-v $(pwd)/backups:/backup \
alpine tar czf /backup/ghost_content_$(date +%Y%m%d).tar.gz -C /data .
docker exec ghost-db mysqldump -u ghost -p"$MYSQL_PASSWORD" ghost | \
gzip > ghost_db_$(date +%Y%m%d).sql.gz
nano ~/backup_ghost.sh
#!/bin/bash
BACKUP_DIR=~/backups/ghost
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
source ~/apps/ghost/.env
# Backup database
docker exec ghost-db mysqldump -u ghost -p"$MYSQL_PASSWORD" ghost | \
gzip > $BACKUP_DIR/ghost_db_$DATE.sql.gz
# Backup content (themes, images, settings)
docker run --rm \
-v ghost_ghost_content:/data \
-v $BACKUP_DIR:/backup \
alpine tar czf /backup/ghost_content_$DATE.tar.gz -C /data .
# Keep 7 days
find $BACKUP_DIR -mtime +7 -delete
echo "Ghost backup complete: $DATE"
chmod +x ~/backup_ghost.sh
# Cron: daily at 2 AM
(crontab -l; echo "0 2 * * * ~/backup_ghost.sh") | crontab -
Ghost is strict about the URL it's configured with. If the configured URL and the actual URL don't match, you'll get redirect loops, mixed content warnings, or broken assets.
The URL must be set before the first setup wizard run. If you set up Ghost with http:// and then enable HTTPS, update the URL in docker-compose.yml, restart, and also run:
docker exec -it ghost node -e "
const knex = require('knex')({
client: 'mysql',
connection: {
host: 'ghost-db', user: 'ghost',
password: process.env.database__connection__password,
database: 'ghost'
}
});
knex('settings').where({key: 'url'}).update({value: 'https://yourdomain.com'});
"
Or simply: set up with the correct HTTPS URL from the start (after Certbot is configured).
cd ~/apps/ghost
# Pull latest Ghost image
docker compose pull ghost
# Restart with new image
docker compose up -d --remove-orphans
# Verify new version
docker exec ghost ghost --version
| Issue | Likely Cause | Fix |
|---|---|---|
| Connection refused | Service not running or wrong port | Check systemctl status SERVICE and verify firewall rules |
| Permission denied | Wrong file ownership or permissions | Check file ownership with ls -la and use chown/chmod to fix |
| 502 Bad Gateway | Backend service not running | Restart the backend service; check logs with journalctl -u SERVICE |
| SSL certificate error | Certificate expired or domain mismatch | Run sudo certbot renew and verify domain DNS points to server IP |
| Service not starting | Config error or missing dependency | Check logs with journalctl -u SERVICE -n 50 for specific error |
| Out of disk space | Logs or data accumulation | Run df -h to identify usage; clean logs or attach CBS storage |
| High memory usage | Too many processes or memory leak | Check with htop; consider upgrading instance plan if consistently high |
| Firewall blocking traffic | Port not open in UFW or Lighthouse console | Open port in Lighthouse console firewall AND sudo ufw allow PORT |
Can I migrate an existing Ghost blog site to a cloud server?
Yes. Export your content and database from the current host, import them on the new server, then update your DNS to point to the new IP. Update any hardcoded URLs in the database or configuration files.
How do I keep Ghost blog updated securely?
Enable automatic minor updates where available, and manually update major versions after testing on a staging environment. Take a Lighthouse snapshot before any significant update.
Do I need a CDN for my Ghost blog site?
For international audiences, yes. A CDN like EdgeOne caches static assets at edge nodes worldwide, improving load times for visitors far from your server region.
What causes Ghost blog to be slow on a VPS?
Usually: missing caching plugin/configuration, unoptimized images, too many plugins, or insufficient server RAM. Start with a caching layer (Redis or built-in cache) and image optimization.
Launch your Ghost blog today:
👉 Tencent Cloud Lighthouse — Ubuntu VPS for Ghost
👉 View current pricing and promotions
👉 Explore all active deals and offers