How I Self-Hosted a Ghost Blog Using Docker, Traefik & AWS
Because why not make your blog about blogging also be your DevOps playground?
💡 Why I Did This
Like many devs, I hit that moment where I wanted a personal blog — but I didn’t just want to spin up a platform. I wanted to build the infrastructure myself, to make it a fun project that was part learning, part feeding the enjoyment of building things.
I chose Ghost for the blog engine (modern, fast, beautiful).
I chose Docker for containerization, Traefik for reverse proxy & TLS, and AWS for the hosting playground.
Why? A mix of elements I was familiar with and are well supported, with some new services I was curious to understand more about. Creating that nice ratio of familiarity to growth that always feels good.
🔧 Stack Overview
Here’s the stack I built:
- 🐳 Docker to containerize everything
- ⚡ Traefik v3 as a smart reverse proxy with automatic HTTPS
- 📰 Ghost as the blog platform
- ☁️ AWS EC2 as the host machine
- 🔐 Let's Encrypt for free TLS
- ⚙️ Shell scripts for bootstrapping
- 🐙 Git for version control
📦 Architecture in a Nutshell
+--------------+ +-------------------+
| Internet 🌍 | <---> | Traefik (TLS + Routing)
+--------------+ +-------------------+
|
v
+----------------------+
| Ghost Blog |
+----------------------+
|
v
+---------------+
| AWS EC2 |
+---------------+
• Traefik handles HTTPS with Let’s Encrypt
• Ghost runs on port 2368, Traefik routes traffic to it
• The entire system is defined with docker-compose.yml
🧱 The Core Files
docker-compose.yml
Defines services: traefik, ghost, and optionally a database. Here’s the gist:
services:
traefik:
image: traefik:v3.3.4
restart: always
container_name: traefik
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.lets-encrypt.acme.email=<your-email>+letsencrypt@gmail.com"
- "--certificatesresolvers.lets-encrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.lets-encrypt.acme.tlschallenge=true"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
networks:
- web
- internal
database:
image: mariadb:11.5
restart: always
env_file:
- .env
networks:
- internal
hostname: db
volumes:
- ./log:/var/log/mysql
- ./lib:/var/lib/mysql
labels:
- "traefik.enable=false"
volumes:
ghost-content:
mysql-data:
mysql-log:
networks:
web:
external: true
internal:
external: false
.env
Used with MariaDB to store creds
MYSQL_ROOT_PASSWORD=<your-root-password-here>
MYSQL_DATABASE=<your-db-name-here>
MYSQL_USER=<your-db-username-here>
MYSQL_PASSWORD=<your-db-user-password-here>
Traefik files
Defines dynamic routing rules & cert resolver for Let’s Encrypt.
traefik.toml
[entryPoints]
[entryPoints.web]
address = ":80"
[entryPoints.web.http.redirections.entryPoint]
to = "websecure"
scheme = "https"
[entryPoints.websecure]
address = ":443"
[api]
dashboard = true
[certificatesResolvers.lets-encrypt.acme]
email = "<your-email>+letsencrypt@gmail.com"
storage = "acme.json"
[certificatesResolvers.lets-encrypt.acme.tlsChallenge]
[providers.docker]
watch = true
network = "web"
[providers.file]
filename = "traefik_dynamic.toml"
[log]
filePath = "traefik.log"
level = "ERROR"
traefik_dynamic.toml
[http.middlewares.simpleAuth.basicAuth]
usersFile = "users.secret"
[http.routers.api]
rule = "Host(`traefik.trenigma.dev`)"
entrypoints = ["websecure"]
middlewares = ["simpleAuth"]
service = "api@internal"
[http.routers.api.tls]
certResolver = "lets-encrypt"
setup.sh
Quick-start script to spin everything up 🚀
#!/bin/bash
# Prompt the user for the domain
read -p "Enter the domain you will be using: " domain
# Prompt the user for the email
read -p "Enter the email for LetsEncrypt notifications: " email
# Replace "trenigma.dev" with the user-provided domain
sed -i '' "s/trenigma.dev/$domain/g" traefik.toml
sed -i '' "s/trenigma.dev/$domain/g" traefik_dynamic.toml
sed -i '' "s/trenigma.dev/$domain/g" README.md
# Replace "your+letsencrypt@email.com" with the user-provided email
sed -i '' "s/<your-email>+letsencrypt@gmail.com/$email/g" traefik.toml
# Copy acme.json.example to acme.json and set permissions
cp acme.json.example acme.json
chmod 0600 acme.json
# Create Docker network
echo "Creating docker network called web"
docker network create web
# Prompt the user for username and password
read -p "Enter the username for basic auth: " username
read -sp "Enter the password for basic auth: " password
echo
# Create a password hash using htpasswd
password_hash=$(echo -n "$password" | openssl passwd -apr1 -stdin)
# Save the username and password hash to users.secret
echo "$username:$password_hash" > users.secret
echo "Setup complete."
echo "You should be able to do a \"docker compose up\" and it'll just start working"
📣 Lazydocker
Special shoutout to Lazydocker as a fantastic tool to make Docker on the box suck less.
install_lazydocker.sh
#!/bin/bash
# allow specifying different destination directory
DIR="${DIR:-"$HOME/.local/bin"}"
# map different architecture variations to the available binaries
ARCH=$(uname -m)
case $ARCH in
i386|i686) ARCH=x86 ;;
armv6*) ARCH=armv6 ;;
armv7*) ARCH=armv7 ;;
aarch64*) ARCH=arm64 ;;
esac
# prepare the download URL
GITHUB_LATEST_VERSION=$(curl -L -s -H 'Accept: application/json' https://github.com/jesseduffield/lazydocker/releases/latest | sed -e 's/.*"tag_name":"\([^"]*\)".*/\1/')
GITHUB_FILE="lazydocker_${GITHUB_LATEST_VERSION//v/}_$(uname -s)_${ARCH}.tar.gz"
GITHUB_URL="https://github.com/jesseduffield/lazydocker/releases/download/${GITHUB_LATEST_VERSION}/${GITHUB_FILE}"
# install/update the local binary
curl -L -o lazydocker.tar.gz $GITHUB_URL
tar xzvf lazydocker.tar.gz lazydocker
install -Dm 755 lazydocker -t "$DIR"
rm lazydocker lazydocker.tar.gz
⚠️ Challenges I Faced
• Domain routing with Traefik: figuring out router rules and how they map to Ghost.
• Let’s Encrypt certificates: initial cert failures were due to labeling issues in traefik configs.
• Mounting config files: Ghost didn’t love misnamed config.production.json paths — fixed it by using correct mount points in Docker.
• Database struggles: Ghost works better with MariaDB than mysql.
💬 What I Learned
• Traefik is really powerful, and its Docker provider makes things super dynamic.
• Ghost works beautifully in Docker once paths are configured right.
• Lazydocker is the shit for dealing with containers on the box. Please go look it up if you've never heard of it.
• You don’t need 15 AWS services — an EC2 box + Docker + smart proxy can go a long way.
• Don’t skip .env
and config validation — saved me hours of debugging.
• Ghost works better with MariaDB than mysql. Also use a .env
file for creds.
🧪 What’s Next?
• Setup Scaleway for transactional emails
• Hook up CI/CD to GitHub Actions
• Monitoring of some kind, maybe Grafana
• Solid backup strategy for the instance
🎉 Wrap-up
Hosting my own Ghost blog wasn’t just about blogging — it was about owning the stack.
Now every post I write lives on infrastructure I understand, manage, and trust.
If you’re thinking about doing the same — do it. It’s an awesome way to level-up your DevOps skills 💪🏽
📬 Got questions or want help? Hit me up on Github. Thanks for reading! ☺️🍻