Self-Hosted Blog Setup: A Complete Guide

What Is a Self-Hosted Blog?

A self-hosted blog runs on infrastructure you control instead of a managed platform like WordPress.com, Medium, or Substack. You own the content, the database, and the domain. No platform can delete your posts, change your monetization terms, or shut down under you. You pick the software, customize it however you want, and pay only for the server.

This guide walks you through the full process: choosing a blogging platform, deploying it with Docker, putting it behind a reverse proxy with SSL, and backing it up.

Prerequisites

Choosing a Platform

Three platforms cover the vast majority of self-hosted blog use cases: Ghost, WordPress, and Hugo. Each makes fundamentally different trade-offs.

FeatureGhostWordPressHugo
TypeDynamic CMSDynamic CMSStatic site generator
LanguageNode.jsPHPGo
DatabaseMySQLMySQL / MariaDBNone (flat files)
EditorBuilt-in Markdown + rich editorGutenberg block editor + pluginsAny text editor (Markdown)
ThemesHandlebars templates10,000+ themes300+ themes
PluginsNone (features are built-in)60,000+ pluginsHugo modules
Memberships & newslettersBuilt-inRequires pluginsNot supported
RAM usage (idle)~250 MB~150 MB0 MB (static HTML)
MaintenanceLowMedium-high (plugin updates, security patches)Very low
SEO toolsBuilt-inVia plugins (Yoast, Rank Math)Manual or build-time
Self-hosting difficultyEasyEasyModerate (build pipeline needed)
Best forWriters, newsletters, membershipsComplex sites needing pluginsDevelopers, high-performance blogs
LicenseMITGPL-2.0Apache-2.0

The recommendation: Ghost for most people. Ghost gives you a polished writing experience, built-in membership and newsletter features, good SEO defaults, and low maintenance overhead. You get 80% of what WordPress offers without the plugin management burden. If you need a specific WordPress plugin ecosystem feature (WooCommerce, advanced forms, LMS), use WordPress. If you want maximum performance and are comfortable with a build step, use Hugo.

For a deeper comparison, see Ghost vs WordPress.

Ghost is a Node.js application backed by MySQL. The Docker setup is straightforward.

Create a project directory:

mkdir -p ~/ghost && cd ~/ghost

Create a docker-compose.yml file:

services:
  ghost:
    image: ghost:5.118.0
    container_name: ghost
    restart: unless-stopped
    environment:
      database__client: mysql
      database__connection__host: ghost-db
      database__connection__user: ghost
      database__connection__password: ${GHOST_DB_PASSWORD}
      database__connection__database: ghost
      # REQUIRED: Set this to your actual blog URL (with https://)
      url: https://blog.example.com
      NODE_ENV: production
      # Uncomment and configure for newsletter/member signup emails:
      # mail__transport: SMTP
      # mail__options__host: smtp.mailgun.org
      # mail__options__port: 587
      # mail__options__auth__user: [email protected]
      # mail__options__auth__pass: ${SMTP_PASSWORD}
      # mail__from: [email protected]
    volumes:
      - ghost-content:/var/lib/ghost/content
    ports:
      - "2368:2368"
    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: ${GHOST_DB_PASSWORD}
    volumes:
      - ghost-db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  ghost-content:    # Themes, images, uploaded files
  ghost-db-data:    # MySQL database files

Create a .env file alongside it:

# Ghost database credentials — CHANGE THESE
GHOST_DB_PASSWORD=replace-with-a-strong-password
MYSQL_ROOT_PASSWORD=replace-with-a-different-strong-password

# Uncomment if configuring SMTP
# SMTP_PASSWORD=your-smtp-password

Start the stack:

docker compose up -d

Ghost is now running on port 2368. Visit http://your-server-ip:2368/ghost/ to create your admin account. Do this immediately — the first person to visit this URL becomes the site owner.

For the full Ghost guide including theme management, member configuration, and advanced settings, see How to Self-Host Ghost.

Option 2: Deploy WordPress with Docker

WordPress uses PHP and needs either MySQL or MariaDB. MariaDB is the better choice for self-hosting — it is a drop-in MySQL replacement with better performance and truly open-source governance.

Create a project directory:

mkdir -p ~/wordpress && cd ~/wordpress

Create a docker-compose.yml file:

services:
  wordpress:
    image: wordpress:6.7.2-php8.3-apache
    container_name: wordpress
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: wordpress-db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: ${WP_DB_PASSWORD}
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_TABLE_PREFIX: wp_
    volumes:
      - wordpress-data:/var/www/html
    ports:
      - "8080:80"
    depends_on:
      wordpress-db:
        condition: service_healthy
    networks:
      - wp-net

  wordpress-db:
    image: mariadb:11.7
    container_name: wordpress-db
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD: ${WP_DB_PASSWORD}
    volumes:
      - wordpress-db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - wp-net

volumes:
  wordpress-data:     # WordPress core files, themes, plugins, uploads
  wordpress-db-data:  # MariaDB database files

networks:
  wp-net:
    driver: bridge

Create a .env file:

# WordPress database credentials — CHANGE THESE
WP_DB_PASSWORD=replace-with-a-strong-password
MARIADB_ROOT_PASSWORD=replace-with-a-different-strong-password

Start the stack:

docker compose up -d

Visit http://your-server-ip:8080 to run the WordPress installation wizard. Choose a username other than “admin” — automated attacks target that username constantly.

For the full WordPress guide including performance tuning and plugin recommendations, see How to Self-Host WordPress.

Option 3: Deploy Hugo (Static Site)

Hugo generates static HTML files from Markdown content. There is no server-side runtime — you build the site locally or in CI, then serve the output with any web server. This means zero attack surface and near-instant page loads.

The trade-off: you need a build pipeline. Hugo does not have a web-based editor or admin panel. You write Markdown files, run hugo build, and deploy the output.

Install Hugo

On Ubuntu/Debian:

# Hugo extended edition (required for most themes)
wget https://github.com/gohugoio/hugo/releases/download/v0.145.0/hugo_extended_0.145.0_linux-amd64.deb
sudo dpkg -i hugo_extended_0.145.0_linux-amd64.deb
hugo version

Create a Site

hugo new site myblog
cd myblog
git init

Add a Theme

# Example: PaperMod theme (popular, fast, SEO-friendly)
git submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod

Add to hugo.toml:

baseURL = "https://blog.example.com/"
languageCode = "en-us"
title = "My Blog"
theme = "PaperMod"

[params]
  defaultTheme = "auto"
  ShowReadingTime = true
  ShowShareButtons = true
  ShowPostNavLinks = true
  ShowBreadCrumbs = true

Write and Build

# Create a post
hugo new content posts/my-first-post.md

# Build the site (output goes to public/)
hugo --minify

# Serve locally for preview
hugo server -D

Serve with Docker

Once built, serve the public/ directory with Nginx or Caddy. Here is a Docker Compose setup using Nginx:

services:
  hugo-blog:
    image: nginx:1.27-alpine
    container_name: hugo-blog
    restart: unless-stopped
    volumes:
      - ./public:/usr/share/nginx/html:ro
    ports:
      - "8090:80"

Rebuild and redeploy whenever you publish new content:

hugo --minify
docker compose restart hugo-blog

For the full Hugo guide including CI/CD deployment pipelines, see How to Self-Host Hugo.

Domain and DNS Setup

Regardless of which platform you choose, your blog needs a domain name pointing to your server.

  1. Register a domain (or use an existing one) with any registrar — Cloudflare, Namecheap, Porkbun all work.
  2. Create an A record pointing to your server’s public IP address:
TypeNameValueTTL
Ablog203.0.113.50 (your server IP)Auto
  1. Wait for DNS propagation (usually under 5 minutes with Cloudflare, up to 48 hours with some registrars).
  2. Verify by pinging the domain:
ping blog.example.com

If you are using Cloudflare as your DNS provider, you can also set up a Cloudflare Tunnel instead of exposing your server’s IP directly.

Reverse Proxy and SSL

Running your blog directly on port 2368 or 8080 without encryption is not acceptable for production. You need a reverse proxy to handle SSL termination and serve your site on port 443.

The simplest option for most people is Nginx Proxy Manager — it provides a web UI for managing proxy hosts and handles Let’s Encrypt certificates automatically. For a deeper look at reverse proxy concepts, see Reverse Proxy Explained.

Nginx Proxy Manager Setup

If you do not already have a reverse proxy running, add Nginx Proxy Manager to your stack:

services:
  npm:
    image: jc21/nginx-proxy-manager:2.14.0
    container_name: nginx-proxy-manager
    restart: unless-stopped
    ports:
      - "80:80"      # HTTP
      - "443:443"    # HTTPS
      - "81:81"      # Admin UI
    volumes:
      - npm-data:/data
      - npm-letsencrypt:/etc/letsencrypt

volumes:
  npm-data:
  npm-letsencrypt:

After starting it with docker compose up -d, access the admin panel at http://your-server-ip:81. Default credentials: [email protected] / changeme.

Add a proxy host:

  1. Domain Names: blog.example.com
  2. Scheme: http
  3. Forward Hostname / IP: ghost (or wordpress, depending on your platform — use the container name)
  4. Forward Port: 2368 for Ghost, 80 for WordPress
  5. SSL tab: Request a new SSL certificate, enable Force SSL and HTTP/2

Your blog is now accessible at https://blog.example.com with automatic certificate renewal.

For more reverse proxy options (Traefik, Caddy), see Reverse Proxy Explained.

Backup Strategy

Your blog has two critical data sources: the application data (themes, uploads, config) and the database. Lose either one and you lose the blog.

Ghost Backup

Ghost stores content in two places:

  • MySQL database — all posts, settings, members, tags
  • Content volume (ghost-content) — themes, images, uploaded files

Back up both:

# Database dump
docker exec ghost-db mysqldump -u root -p"$(cat .env | grep MYSQL_ROOT_PASSWORD | cut -d= -f2)" ghost > ghost-db-backup.sql

# Content volume
docker run --rm -v ghost-content:/data -v $(pwd):/backup alpine tar czf /backup/ghost-content-backup.tar.gz /data

WordPress Backup

WordPress stores content in:

  • MariaDB database — all posts, pages, settings, user accounts
  • WordPress data volume (wordpress-data) — themes, plugins, uploads in wp-content/
# Database dump
docker exec wordpress-db mariadb-dump -u root -p"$(cat .env | grep MARIADB_ROOT_PASSWORD | cut -d= -f2)" wordpress > wordpress-db-backup.sql

# Data volume
docker run --rm -v wordpress-data:/data -v $(pwd):/backup alpine tar czf /backup/wordpress-data-backup.tar.gz /data

Hugo Backup

Hugo is the simplest to back up. Your entire site is source files — Markdown, config, and theme. If you use Git (and you should), your repository is the backup. Push to a remote regularly:

git add -A && git commit -m "Content update" && git push origin main

Automate It

Schedule backups with cron. Run daily at minimum:

crontab -e
0 3 * * * /home/user/ghost/backup.sh >> /home/user/ghost/backup.log 2>&1

For a complete backup strategy including offsite storage and restore testing, see Backup Strategy.

Post-Setup Checklist

After your blog is live, verify these before publishing your first post:

CheckGhostWordPressHugo
SSL working (padlock in browser)RequiredRequiredRequired
Admin panel accessible/ghost//wp-admin/N/A
Default credentials changedFirst-visit setupSet during installN/A
Email sending configuredOptional (needed for members)Optional (needed for notifications)N/A
Permalink structure setAutomaticSettings > Permalinks > “Post name”Set in hugo.toml
Sitemap generatedAutomatic at /sitemap.xmlRequires plugin (Yoast)Built-in with config
RSS feed workingAutomatic at /rss/Automatic at /feed/Automatic at /index.xml
Backup testedTest restore from dumpTest restore from dumpVerify Git remote

Common Mistakes

Using :latest Docker image tags. Pin to a specific version. When you run docker compose pull months later, :latest can jump major versions and break your site. Pinning gives you control over when you upgrade.

Forgetting to set the url in Ghost. Ghost uses the url environment variable to generate all internal links, sitemap entries, and canonical URLs. If this is wrong or still set to http://localhost:2368, your SEO and social sharing will break silently.

Skipping database backups. Volume backups alone are not enough. A MySQL/MariaDB data directory copied while the database is running can be corrupted. Always use mysqldump or mariadb-dump for a consistent snapshot.

Exposing the blog directly without a reverse proxy. Running on port 2368 or 8080 without SSL means credentials travel in plaintext. Always put a reverse proxy in front, even for a personal blog.

Choosing WordPress “because everyone uses it.” WordPress is powerful but requires ongoing maintenance — plugin updates, security patches, PHP version management. If you just want to write and publish, Ghost is significantly less work to maintain.

Next Steps

Your blog is deployed, encrypted, and backed up. From here:

  • Write your first post and verify it renders correctly
  • Configure email if you want newsletter or membership features (Ghost) or notification emails (WordPress)
  • Set up monitoring to know when your blog goes down — see Uptime Monitoring
  • Optimize performance with caching and CDN configuration
  • Explore other self-hosted CMS options in Best Self-Hosted CMS & Websites

Comments