From manual Docker/Nginx proxy to Traefik and Ansible: Modernizing my Ghost blog stack in 2026

This blog has run Ghost in Docker since 2020, manually deployed. In 2026 I set the goal to use Traefik as a modern reverse proxy. Learn the first steps with Traefik, Docker and Ghost, and how to automate with Ansible — including domain redirects, TLS certificates, version pinning, and backups.

From manual Docker/Nginx proxy to Traefik and Ansible: Modernizing my Ghost blog stack in 2026
Traefik HTTP services dashboard

History

Ghost is an open-source CMS that offers a blog with light-weight editing and Markdown formatting, social cards, and more, including a newsletter system. My blog on dnsmichi.at has been running in a Ghost Docker container since March 2020. Back then, I was confident with Docker and NGinx and aimed for a simple setup:

  1. Ghost setup with unofficial container images from Docker Hub: ghost-server, NodeJS app, ghost-db, MySQL backend
  2. NGinx HTTP proxy on the Ubuntu system, with Let's Encrypt TLS certificate automation

Problem: No automation and hardened best practices to re-create the setup in case of emergencies.

2026 Goal: Modernize my stack

In 2024, I learned the pros and cons of the NGinx proxy and ACME companion, and after sharing the Umami blog post, folks recommended looking into Traefik as an alternative.

I found new inspiration at Container Days 2025, where I hosted a track featuring the fine folks from Traefik Labs who presented the latest insights into Gateway API. I also knew that Daniel Bodky uses Traefik in their homelab. Along with improving my Ansible skills (LLMs on Embedded talk), I set a goal to put Traefik in production in 2026.

Additional goals: Upgrade Ghost to 6, refine the theme with dark mode and migrate the domain to https://dnsmichi.com/

Getting started with Traefik

Traefik is a cloud-native application reverse proxy that uses service discovery to dynamically configure routing, e.g., to containers running on a host and in Kubernetes. Traefik supports SSL/TLS termination and works with ACME providers like Lets Encrypt to automatically fetch TLS certificates. Everything I need for my Ghost container setup.

For the first steps with Traefik, I followed the official Getting Started guide with Docker and learned how labels in Docker compose services play a big role in Traefik configuration. I started my Traefik-in-production research in the faun.dev() blog to learn how Ghost can run with Traefik as a reverse proxy. Much of my first working iteration is thanks to the detailed explanations in their blog – thank you, Daniel Niecke ✨ I also asked Claude lots of questions and got to success faster with a little help from AI; prompts are documented here.

The next sections describe the setup with Docker Compose as the first learning success. The Ansible-automated version follows afterward in my setup learning iteration.

Readers' note: For simplicity, the domain in configuration examples is blogdomain.com - the production example is this blog dnsmichi.com .

Preparations

Create a new VM in your preferred cloud environment.

Prepare the blogdomain.com DNS records for A (IPv4) and AAAA (IPv6).

DNS records A, AAAA, TXT for dnsmichi.com

My setup uses Ubuntu 24 LTS in Hetzner Cloud, cpx32 host type, Shared Regular Performance (4 CPU, 8GB RAM) provisioned with Terraform/OpenTofu. The VM will also host analytics, observability and CI/CD automation in the future.

SSH into the host, and become root.

sudo -i

Install Docker and Docker compose on host. Example for Ubuntu 24 LTS:

apt update
apt install -y ca-certificates curl

install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  tee /etc/apt/sources.list.d/docker.list > /dev/null

apt update

apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Verify the setup:

docker --version

# Docker version 29.2.1, build a5c7197

docker compose version

# Docker Compose version v5.0.2

Docker compose configuration

Create the directory /docker/web and docker-compose.yml

mkdir -p /docker/web

touch /docker/web/docker-compose.yaml

Define the container networks:

  1. frontend_net for web services (Traefik reverse proxy, Ghost server)
  2. database_net for database backend (Ghost server to Ghost DB). Containers are not exposed to reverse proxy.
vim /docker/web/docker-compose.yml
---
networks:
  frontend_net:
  database_net:

Traefik in Docker Compose

Edit the docker-compose.yml file and add a new service reverse-proxy.

vim /docker/web/docker-compose.yml
services:

  ##############################################################
  # Traefik reverse proxy
  
  reverse-proxy:
  

The latest Traefik version is 3.6 - modify the image tag for your own needs, do not use latest.

    image: traefik:v3.6

Runtime configuration for Traefik in the command block:

  1. Configure Docker as provider --providers.docker, and disable containers exposed as default --providers.docker.exposedByDefault=false (important for later container additions).
  2. The --entrypoints.web labels block configures the redirect from port 80 to 443 (https) and its target websecure as entrypoint (see 3. next).
  3. --entrypoints.websecure.address=:443 runs Traefik on the host's port 443, capturing all incoming HTTPS traffic.
  4. --certificatesresolvers.letsencrypt.acme configures Lets Encrypt with an email, and where to persistently store the TLS certificates.
    1. The storage directory letsencrypt is mounted as container volumes
    command:
      - "--api.dashboard=true"
      - "--api.insecure=false"
      - "--accesslog=true"

      - "--providers.docker"
      - "--providers.docker.exposedByDefault=false"

      - "--entrypoints.web.address=:80"
      - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
      - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
      - "--entrypoints.web.http.redirections.entrypoint.permanent=true"

      - "--entrypoints.websecure.address=:443"

      - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email=email@domain.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"

Additional container configuration:

  1. ports: Maps port 80 and 443 from the host OS to Traefik container.
  2. networks: Communicates in the frontend_net network only.
    1. Add any web frontend services to this network for Traefik service discovery.
  3. volumes: Mount the Docker socket into the container for Traefik to listen on events. Also mount the persisted Lets Encrypt certificates.
    ports:
      - "80:80"
      - "443:443"
    networks:
      - frontend_net
    volumes:
      # Traefik can listen to the Docker events
      - /var/run/docker.sock:/var/run/docker.sock
      - ./letsencrypt:/letsencrypt

You can save the configuration (full example in the Appendix) and start it with docker compose up -d. We won't see anything yet, so let's configure the Traefik dashboard first.

Traefik dashboard

The Traefik dashboard can be enabled in the Traefik service using --api.dashboard=true in the command section above.

The Traefik Dashboard can be configured using Docker compose service labels.

  1. Domain and Vhost: Configure DNS for traefik.blogdomain.com first.
  2. Lets Encrypt certificate using Traefik's ACME Lets Encrypt configuration above.
  3. Middleware authentication with basic auth.

The Traefik dashboard supports basic auth. Use htpassword from the apache2-utils package on Debian/Ubuntu to generate a password hash, and store the user/password in a password safe like 1Password. The -nB option uses bcrypt, which is supported by Traefik.

apt install apache2-utils

htpasswd -nB admin

# Output: admin:$2y$05$bk2QPMvrAzGAgqzFQkBSPu9DedOgHBRXoJM68IIQTYbTqMW0aWM6q

Configure the service labels and replace traefik.blogdomain.com and the basic auth users admin:$2y... string.

    labels:
      - "traefik.enable=true"

      # Dashboard router
      - "traefik.http.routers.dashboard.rule=Host(`traefik.blogdomain.com`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.tls=true"
      - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=dashboard-auth"

      # BasicAuth middleware
      - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$2y$05$bk2QPMvrAzGAgqzFQkBSPu9DedOgHBRXoJM68IIQTYbTqMW0aWM6q"

      # Keep the prefix web_ in sync with the deploy target in /docker/web (Ansible)
      - "traefik.docker.network=web_frontend_net"

Note: The documentation recommends disabling the dashboard and API for production usage. Take a note for later to set --api.dashboard=false .

Start the Docker compose stack:

docker compose up -d

Navigate to https://traefik.blogdomain.com and log in using the basic auth credentials.

Traefik dashboard overview example

Note: The full docker-compose.yml is available in the Appendix.

Ghost with Traefik in Docker Compose

Ghost requires two services:

  1. Ghost server, NodeJS app, image on Docker Hub
  2. MySQL database backend

My configuration is based on the Docker Hub readme, and my own learnings since 2020. Let's transform this configuration working with Traefik.

Ghost server

Add a new service ghost-server and specify the settings. Important notes:

  1. image: The latest Ghost release is 6 where the image is tagged with. Major versions sometimes break behavior, which is why I prefer tested upgrades with backups. I also learned that Ghost 6 plans to use Docker as default setup.
  2. depends_on: Wait until the ghost-db container comes up healthy. Ghost performs database migrations on startup which would otherwise fail.
  3. volumes: Mount /docker/web/ghost/content into the container as persistent volume.
  4. networks: Specify database_net for communication with the ghost-db container, and frontend_net for Traefik as reverse proxy.
    1. Important: Do not expose ports - Traefik discovers the port through the frontend_net network.
  5. labels: Define the blogdomain host for TLS only (websecure) and Lets Encrypt TLS certificate fetch, set the Ghost port discovery explicitely, and define the frontend_net network for Traefik.
# /docker/web/docker-compose.yml
services:
  # ...
  
  ##############################################################
  # Ghost

  ghost-server:
    image: ghost:6
    restart: always
    env_file: ".ghost.env"

    depends_on:
      ghost-db:
        condition: service_healthy
    volumes:
      - /docker/web/ghost/content:/var/lib/ghost/content
    networks:
      # Talk to backend database
      - database_net
      # Frontend is exposed to Traefik
      - frontend_net
    labels:
      - "traefik.enable=true"

      - "traefik.http.routers.my-ghost-blog.rule=Host(`blogdomain.com`)"
      - "traefik.http.routers.my-ghost-blog.entrypoints=websecure"
      - "traefik.http.routers.my-ghost-blog.tls=true"

      # Keep this linked to Traefik label certificatesresolvers name.
      - "traefik.http.routers.my-ghost-blog.tls.certresolver=letsencrypt"

      - "traefik.http.routers.my-ghost-blog.tls.domains[0].main=blogdomain.com"

      # Explicitely point to Ghost port
      - "traefik.http.services.my-ghost-blog.loadbalancer.server.port=2368"

      # Keep the prefix web_ in sync with the deploy target in /docker/web (Ansible)
      - "traefik.docker.network=web_frontend_net"

Note: Docker compose automatically adds the directory web/ as a prefix to all Docker settings, hence networks: frontend_net becomes traefik.docker.network=web_frontend_net as labels entry.

Ghost database (MySQL)

Ghost needs a MySQL 8 database container named ghost-db. This is also the database hostname in the .ghost.env configuration (see next section).

  1. image: Ghost works best with MySQL 8 or compatible services. My latest tested MySQL version is 8.4. I only gradually upgrade the backend because I keep running into breaking changes in X.Y versions.
  2. volumes: Mount the MySQL database storage from /docker/web/ghost/mysql.
  3. networks: The network is limited to database_net
    1. Traefik service discovery is disabled in the labels with traefik.enable=false . This mitigates the risk of accidentally exposing the database port 3306 to the web.
  4. healthcheck: The Ghost server depends on the MySQL database container; therefore, an additional health check is added to ensure the database is up and running before the Ghost app tries to connect/run database migrations.
# /docker/web/docker-compose.yml
services:
  # ...

  # https://ghost.org/docs/faq/supported-databases/
  ghost-db:
    image: mysql:8.4
    restart: always
    env_file: ".ghost.env"

    volumes:
      - /docker/web/ghost/mysql:/var/lib/mysql
    networks:
      # Limit to database backend only
      - database_net

    healthcheck:
      test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
      interval: 1s
      timeout: 20s
      retries: 10
      start_period: 10s

    labels:
      - "traefik.enable=false"

Credentials and config in .ghost.env

By default, the Ghost and MySQL containers expect credentials and configuration as environment variables. Create a .ghost.env file in /docker/web/ and limit its permission to root read-write only (0600).

The configuration file also includes the url parameter for the Ghost server, which needs to be prefixed with https://.

vim /docker/web/.ghost.env
# /docker/web/.ghost.env

# ghost server
url=https://blogdomain.com

database__client=mysql

# This needs to be in sync with docker-compose host name.
database__connection__host=ghost-db

database__connection__user=root
database__connection__password=CHANGEMESUPERSECRET
database__connection__database=ghost
security__staffDeviceVerification=false

# ghost-db server
MYSQL_ROOT_PASSWORD=CHANGEMESUPERSECRET

Ensure that the .ghost.env file is referenced in the docker-compose.yml services definition:

# /docker/web/docker-compose.yml
services:
  # ...

  ghost-server:
    # ... 
    env_file: ".ghost.env"
  
  ghost-db:
    # ... 
    env_file: ".ghost.env"

Restart Docker compose and verify

docker compose pull

docker compose up -d

curl -Lvv blogdomain.com 

Note: The full docker-compose.yml is available in the Appendix.

Results: Ghost with Traefik in Docker

Open blogdomain.com in your browser and start building your Ghost blog.

You can verify the setup in the Traefik dashboard with the HTTP services; search for blogdomain.com.

Traefik dashboard with dnsmichi domains

Inspect the TLS certificates with sslscan and other tools.

sslscan output for dnsmichi.com

The architecture flow with Docker networks and Traefik looks like this:

Architecture flow example with Traefik and Docker networks

Need more automation and security? This blog on https://dnsmichi.com/ uses the Ansible automation documented below.

First steps with Ghost: Settings

Log into blogdomain.com/ghost and open the settings. I kept the changes minimal for dnsmichi.com.

  1. Site:
    1. Design & Branding: Publication cover, Typography (dnsmichi.com: Inter).
    2. Theme: Casper (default, on dnsmichi.com)
    3. Navigation: Primary menu
  2. Advanced
    1. Code inspections: A side TOC menu and code block highlights (source, dnsmichi.com assets here)

Migrating from an existing Ghost setup

See my documented steps in the Appendix describing the Ghost-in-Docker migration from dnsmichi.at to dnsmichi.com (2 VMs).

Optional: Redirects with Traefik

Redirecting an HTTP(S) request usually requires a minimal web server container and configuration. Fortunately, Traefik thought of that:

  1. Redirects configured as labels
  2. Minimal Traefik runtime that performs the redirect operations: traefik/whoami

Benefit: Saves the workload and configuration of an NGinx container.

The following example redirects https://dnsmichi.dev to https://dnsmichi.com/about/. I registered the domain for my Bluesky handle and needed a "who am i" web host. It configures TLS as default, with Lets Encrypt certificate fetch, and the permanent redirect.

# /docker/web/docker-compose.yml

services:
  # ...

  ##############################################################
  # dnsmichi.dev redirect to dnsmichi.com/about

  dnsmichi-dev-redirect:
    image: traefik/whoami
    restart: always
    networks:
      - frontend_net
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dnsmichi-dev.rule=Host(`dnsmichi.dev`)"
      - "traefik.http.routers.dnsmichi-dev.entrypoints=websecure"
      - "traefik.http.routers.dnsmichi-dev.tls=true"
      - "traefik.http.routers.dnsmichi-dev.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dnsmichi-dev.middlewares=dnsmichi-dev-redir"
      - "traefik.http.middlewares.dnsmichi-dev-redir.redirectregex.regex=.*"
      - "traefik.http.middlewares.dnsmichi-dev-redir.redirectregex.replacement=https://dnsmichi.com/about"
      - "traefik.http.middlewares.dnsmichi-dev-redir.redirectregex.permanent=true"
      - "traefik.http.services.dnsmichi-dev.loadbalancer.server.port=80"
      - "traefik.docker.network=web_frontend_net"

Note: Docker compose automatically adds the directory web/ as a prefix to all Docker settings, hence networks: frontend_net becomes traefik.docker.network=web_frontend_net as labels entry.

Automation with Ansible

This section aims to fully automate the configuration and deployment of Traefik with Ghost in Docker using Ansible. It is documented separately, as I first learned the manual setup with Traefik and then developed the Ansible setup.

Changes from manual to automation

It can be helpful to understand the manual setup concepts first. The required changes are:

  1. Generic Docker compose configuration
    1. VHosts, ACME emails, etc. managed with Ansible host_vars, use Jinja templates
    2. Docker image versions managed with group_vars.
  2. .env files with root-only permissions (0600)
    1. Secrets managed with Ansible Vault, .env files as Jinja templates
  3. Base OS hardening, Ops tooling, additional configuration, and Backups
    1. Managed with Ansible

Ansible environment

Install Ansible on your host, e.g. using Homebrew on macOS:

brew install ansible ansible-lint 

Initialize the main structure. You can clone/fork my project and modify it for your needs: https://gitlab.com/dnsmichi/automated-michi.fyi-dnsmichi-com or follow the steps below.

I've developed the follow structure from learned best practices:

  1. inventory managing the production environment and providing group_vars with the Ansible vault and image tag versions. host_vars contains public details like ACME email.
  2. playbooks
    1. web.yml for preparing the Docker compose environment.
    2. base-os.yml , base-upgrade.yml and backup-ghost.yml for specific host OS packages, firewall/SSH hardening, unattended upgrades and backups.
    3. templates for provisioning the host target /docker/web/docker-compose.yml with Jinja templates.
  3. roles/requirements.yml specifying collections and roles.
  4. Configuration files
    1. .ansible.cfg for Ansible configuration
    2. .ansible-lint for instructions for ansible-lint CLI
    3. .gitignore ignoring cache files, and .vault-password and vault.yml files in Git.
    4. AGENTS.md providing Agentic AI instructions for Ansible
tree -L6 -a
.
├── .ansible-lint
├── .gitignore
├── .vault-password
├── AGENTS.md
├── ansible.cfg
├── inventory
│   ├── group_vars
│   │   └── all
│   │       ├── vars.yml
│   │       └── vault.yml
│   ├── host_vars
│   │   └── blogdomain.yml
│   └── production.yml
├── playbooks
│   ├── backup-ghost.yml
│   ├── base-os.yml
│   ├── base-upgrade.yml
│   ├── templates
│   │   └── docker
│   │       └── web
│   │           ├── docker-compose.yml.j2
│   │           └── run.sh
│   └── web.yml
└── roles
    └── requirements.yml

Production inventory

Specify the Ansible user and SSH private key. The blogdomain host sits in the web_servers group, in case you need group specific variables.

vim inventory/production.yml
---
all:
  vars:
    ansible_user: 'usernameCHANGEME'
    ansible_ssh_private_key_file: '~/.ssh/id_ed25519_CHANGEME'
  children:
    web_servers:
      hosts:
        blogdomain:

Ansible variables: group and host

Group variables manage the container image versions, backup retention times. etc. This configuration intentionally uses the all directory to ensure versions are the same across my stack.

Note: I developed this after hardcoding the image tags in docker-compose first.

Update the versions in the group variables:

mkdir -p inventory/group_vars/all/
vim inventory/group_vars/all/vars.yml
# group_vars/all/vars.yml
traefik_version: "v3.6"
ghost_version: "6"
ghost_mysql_version: "8.4"
nginx_version: "1.27-alpine"

# Backup
backup_retention_days: 365

Manage VHosts in the host variables:

Replace blogdomain.com as file path with your host domain, and modify the specific variable values in the vars.yml file, too.

mkdir -p inventory/host_vars/blogdomain.com/
vim inventory/host_vars/blogdomain.com/vars.yml
# Web
default_vhost: "domain.com"

ghost_vhost: "blogdomain.com"

traefik_vhost: "traefik.blogdomain.com"

acme_default_email: "user@domain.com"

Secrets in Ansible Vault

Secrets in Ansible should be managed with Ansible Vault. Create the vault.yml with ansible-vault .

ansible-vault create inventory/group_vars/all/vault.yml

Add the Ghost database and Traefik dashboard credentials.

ghost_db_user: "root"
ghost_db_password: "CHANGEMESUPERSECRET"

traefik_dashboard_users: "admin:$2y$05$CHANGEME"

Encrypt the vault and store the password in a password safe like 1Password.

You can edit the vault again using the following command:

ansible-vault edit inventory/group_vars/all/vault.yml

To avoid interactive password questions in the future (e.g. in CI/CD), you can store the vault password in a local file, and configure Ansible to read from it.

echo "your-vault-password" > .vault-password
chmod 600 .vault-password
ansible-playbook playbooks/web.yml --vault-password-file .vault-password

If you want to omit the --vault-password-file parameter, edit ansible.cfg and add:

[defaults]
vault_password_file = .vault-password

Ansible Jinja templates for Docker Compose and .env

Ansible supports Jinja templates with variable placeholders for dynamic file generation on target hosts.

Migrate the existing docker-compose.yml and .ghost.env files into Jinja templates in playbooks/templates/web/docker/ and add the .j2 file suffix.

Replace configuration variables and sensitive credentials with group/host vars and Ansible vault variables using the {{ variable_name }} syntax.

Edit playbooks/templates/web/docker/.ghost.env.j2 to use ghost_vhost, ghost_db_user and ghost_db_password variables.

vim playbooks/templates/web/docker/.ghost.env.j2
# ghost server
url=https://{{ ghost_vhost }}

database__client=mysql

# This needs to be in sync with docker-compose host name.
database__connection__host=ghost-db

database__connection__user={{ ghost_db_user }}
database__connection__password={{ ghost_db_password }}
database__connection__database=ghost
security__staffDeviceVerification=false

# ghost-db server
MYSQL_ROOT_PASSWORD={{ ghost_db_password }}

All web Docker containers are combined in a single docker-compose.yml file for easier management. Edit playbooks/templates/web/docker/docker-compose.yml.j2 and use the following variables:

  1. Traefik: traefik_version , acme_default_email, traefik_vhost, traefik_dashboard_users
  2. Ghost: ghost_version, ghost_vhost, ghost_mysql_version,
vim playbooks/templates/web/docker/docker-compose.yml.j2
---
networks:
  frontend_net:
  database_net:

services:

  ##############################################################
  # Traefik reverse proxy

  reverse-proxy:
    image: traefik:{{ traefik_version }}
    command:
      - "--api.dashboard=true"
      - "--api.insecure=false"
      - "--accesslog=true"

      - "--providers.docker"
      - "--providers.docker.exposedByDefault=false"

      - "--entrypoints.web.address=:80"
      - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
      - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
      - "--entrypoints.web.http.redirections.entrypoint.permanent=true"

      - "--entrypoints.websecure.address=:443"

      - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email={{ acme_default_email }}"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
    ports:
      - "80:80"
      - "443:443"
    networks:
      - frontend_net
    volumes:
      # Traefik can listen to the Docker events
      - /var/run/docker.sock:/var/run/docker.sock
      - ./letsencrypt:/letsencrypt
    labels:
      - "traefik.enable=true"

      # Dashboard router
      - "traefik.http.routers.dashboard.rule=Host(`{{ traefik_vhost }}`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.tls=true"
      - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=dashboard-auth"

      # BasicAuth middleware
      - "traefik.http.middlewares.dashboard-auth.basicauth.users={{ traefik_dashboard_users }}"

      # Keep the prefix web_ in sync with the deploy target in /docker/web (Ansible)
      - "traefik.docker.network=web_frontend_net"


  ##############################################################
  # Ghost

  ghost-server:
    image: ghost:{{ ghost_version }}
    restart: always
    env_file: ".ghost.env"

    depends_on:
      ghost-db:
        condition: service_healthy
    volumes:
      - /docker/web/ghost/content:/var/lib/ghost/content
    networks:
      # Talk to backend database
      - database_net
      # Frontend is exposed to Traefik
      - frontend_net
    labels:
      - "traefik.enable=true"

      - "traefik.http.routers.my-ghost-blog.rule=Host(`{{ ghost_vhost }}`)"
      - "traefik.http.routers.my-ghost-blog.entrypoints=websecure"
      - "traefik.http.routers.my-ghost-blog.tls=true"

      # Keep this linked to Traefik label certificatesresolvers name.
      - "traefik.http.routers.my-ghost-blog.tls.certresolver=letsencrypt"

      - "traefik.http.routers.my-ghost-blog.tls.domains[0].main={{ ghost_vhost }}"

      # Explicitely point to Ghost port
      - "traefik.http.services.my-ghost-blog.loadbalancer.server.port=2368"

      # Keep the prefix web_ in sync with the deploy target in /docker/web (Ansible)
      - "traefik.docker.network=web_frontend_net"

  # https://ghost.org/docs/faq/supported-databases/
  ghost-db:
    image: mysql:{{ ghost_mysql_version }}
    restart: always
    env_file: ".ghost.env"

    volumes:
      - /docker/web/ghost/mysql:/var/lib/mysql
    networks:
      # Limit to database backend only
      - database_net

    healthcheck:
      test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
      interval: 1s
      timeout: 20s
      retries: 10
      start_period: 10s

    labels:
      - "traefik.enable=false"

There are also more specific use cases for my environment in the production configuration not shown here, for example, redirects.

Optional: Docker compose helper on the host

I use this helper script when SSH'ing on the target host for debugging.

vim playbooks/templates/web/docker/run.sh
#!/bin/sh

docker compose pull && docker compose up -d --force-recreate --build

Ansible playbooks

Note: I've created additional playbooks for OS setup, upgrades and backups; you can find them in the Appendix to allow focus on the Docker Compose Traefik/Ghost setup here.

The Ansible playbook playbooks/web.yml prepares the /docker/web path, creates the docker-compose.yml and .ghost.env files from Jinja templates, adds the local run.sh helper, and then deploys the stack with the community.docker.docker_compose_v2 module. When successful, it prints the running containers and image tags.

vim playbooks/web.yml
---
- name: Web
  hosts: all
  remote_user: michi
  become: true
  become_method: ansible.builtin.sudo

  tasks:
    # /docker/web - Traefik, Ghost, etc
    - name: Create web path
      ansible.builtin.file:
        path: /docker/web
        state: directory
        mode: '0750'

    - name: Create docker-compose for web
      ansible.builtin.template:
        src: docker/web/docker-compose.yml.j2
        dest: /docker/web/docker-compose.yml
        mode: '0600'

    - name: Create .env for web - ghost
      ansible.builtin.template:
        src: docker/web/.ghost.env.j2
        dest: /docker/web/.ghost.env
        mode: '0600'

     # Docker compose helper - for manual SSH
    - name: Create compose up recreate script
      ansible.builtin.template:
        src: docker/web/run.sh
        dest: /docker/web/run.sh
        mode: '0700'

    # Docker compose deployment with Ansible
    - name: Deploy web stack
      community.docker.docker_compose_v2:
        project_src: /docker/web
        pull: always
        recreate: always
        state: present

    - name: Check running containers
      ansible.builtin.command:
        cmd: "{% raw %}docker ps --format 'table {{.Names}}\t{{.Image}}'{% endraw %}"
      register: docker_ps

    - name: Show containers
      ansible.builtin.debug:
        var: docker_ps.stdout_lines


Ansible deployment

Deploy everything onto the target host:

ansible-playbook playbooks/web.yml
Ansible playbook output, running Docker containers

After the Docker containers have started, this is the expected directory structure on the target host:

tree /docker/web -L 2

/docker/web
├── docker-compose.yml
├── ghost
│   ├── content
│   └── mysql
├── .ghost.env
├── letsencrypt
│   └── acme.json
└── run.sh

If manual SSH is necessary, use the run.sh helper script while debugging.

ssh user@blogdomain.com

sudo -i

cd /docker/web

./run.sh 

Upgrade Ghost from 5 to 6

Performed while writing this blog post.

  1. Investigated the 6.0 changelog and asked Claude about breaking changes. Nothing to worry about.
  2. Took a backup with the newly created Ansible playbook playbooks/backup-ghost.yml
  3. Updated inventory/group_vars/all/vars.yml with ghost_version: "6"
  4. Ran ansible-playbook playbooks/web.yml
  5. Verified web and SSH'd into the host:

What's next?

I'm working on future stack upgrades and will share my findings:

  1. Add an Observability stack with Prometheus and Grafana in Traefik. Production important.
  2. Automate upgrades with GitLab CI/CD Runner and Ansible.
  3. Migrate the Umami setup to Traefik - currently down to unblock the initial iteration with Traefik and Ghost. Not production critical, data/containers exist.

Appendix

Full configuration, additional Ansible playbooks, Claude prompts, etc.

The complete Ansible automation is available in this GitLab project: https://gitlab.com/dnsmichi/automated-michi.fyi-dnsmichi-com

CI/CD linting

ansible-lint , secrets detection, and IaC SAST scanning are configured in .gitlab-ci.yml.

Docker Compose - full

/docker/web/docker-compose.yml

---
networks:
  frontend_net:
  database_net:

services:

  ##############################################################
  # Traefik reverse proxy

  reverse-proxy:
    image: traefik:v3.6
    command:
      - "--api.dashboard=false"
      - "--api.insecure=false"
      - "--accesslog=true"

      - "--providers.docker"
      - "--providers.docker.exposedByDefault=false"

      - "--entrypoints.web.address=:80"
      - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
      - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
      - "--entrypoints.web.http.redirections.entrypoint.permanent=true"

      - "--entrypoints.websecure.address=:443"

      - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email=user@domain.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
    ports:
      - "80:80"
      - "443:443"
    networks:
      - frontend_net
    volumes:
      # Traefik can listen to the Docker events
      - /var/run/docker.sock:/var/run/docker.sock
      - ./letsencrypt:/letsencrypt
    labels:
      - "traefik.enable=true"

      # Dashboard router
      - "traefik.http.routers.dashboard.rule=Host(`traefik.blogdomain.com`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.tls=true"
      - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=dashboard-auth"

      # BasicAuth middleware
      - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$2y$05$bk2QPMvrAzGAgqzFQkBSPu9DedOgHBRXoJM68IIQTYbTqMW0aWM6q"

      # Keep the prefix web_ in sync with the deploy target in /docker/web (Ansible)
      - "traefik.docker.network=web_frontend_net"


  ##############################################################
  # Ghost

  ghost-server:
    image: ghost:6
    restart: always
    env_file: ".ghost.env"

    depends_on:
      ghost-db:
        condition: service_healthy
    volumes:
      - /docker/web/ghost/content:/var/lib/ghost/content
    networks:
      # Talk to backend database
      - database_net
      # Frontend is exposed to Traefik
      - frontend_net
    labels:
      - "traefik.enable=true"

      - "traefik.http.routers.my-ghost-blog.rule=Host(`blogdomain.com`)"
      - "traefik.http.routers.my-ghost-blog.entrypoints=websecure"
      - "traefik.http.routers.my-ghost-blog.tls=true"

      # Keep this linked to Traefik label certificatesresolvers name.
      - "traefik.http.routers.my-ghost-blog.tls.certresolver=letsencrypt"

      - "traefik.http.routers.my-ghost-blog.tls.domains[0].main=blogdomain.com"

      # Explicitely point to Ghost port
      - "traefik.http.services.my-ghost-blog.loadbalancer.server.port=2368"

      # Keep the prefix web_ in sync with the deploy target in /docker/web (Ansible)
      - "traefik.docker.network=web_frontend_net"

  # https://ghost.org/docs/faq/supported-databases/
  ghost-db:
    image: mysql:8.4
    restart: always
    env_file: ".ghost.env"

    volumes:
      - /docker/web/ghost/mysql:/var/lib/mysql
    networks:
      # Limit to database backend only
      - database_net

    healthcheck:
      test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
      interval: 1s
      timeout: 20s
      retries: 10
      start_period: 10s

    labels:
      - "traefik.enable=false"

  ##############################################################
  # REDIRECTS
  # Use a minimal container image from traefik for this purpose
  ##############################################################

  ##############################################################
  # dnsmichi.dev redirect to dnsmichi.com/about

  dnsmichi-dev-redirect:
    image: traefik/whoami
    restart: always
    networks:
      - frontend_net
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dnsmichi-dev.rule=Host(`dnsmichi.dev`)"
      - "traefik.http.routers.dnsmichi-dev.entrypoints=websecure"
      - "traefik.http.routers.dnsmichi-dev.tls=true"
      - "traefik.http.routers.dnsmichi-dev.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dnsmichi-dev.middlewares=dnsmichi-dev-redir"
      - "traefik.http.middlewares.dnsmichi-dev-redir.redirectregex.regex=.*"
      - "traefik.http.middlewares.dnsmichi-dev-redir.redirectregex.replacement=https://dnsmichi.com/about"
      - "traefik.http.middlewares.dnsmichi-dev-redir.redirectregex.permanent=true"
      - "traefik.http.services.dnsmichi-dev.loadbalancer.server.port=80"
      # Keep the prefix web_ in sync with the deploy target in /docker/web (Ansible)
      - "traefik.docker.network=web_frontend_net"

Ansible playbook: Base OS

The goal is to install often required packages, harden the host OS (UFW firewall, SSH) and optimize the logging for UFW (spams syslog by default on Ubuntu).

---
- name: Base OS Setup
  hosts: all
  become: true
  become_method: ansible.builtin.sudo
  roles:
    - role: hifis.toolkit.unattended_upgrades
      unattended_origins_patterns:
        - 'origin=Ubuntu,archive=${distro_codename}-security'
        - 'o=Ubuntu,a=${distro_codename}'
        - 'o=Ubuntu,a=${distro_codename}-updates'
        - 'o=Ubuntu,a=${distro_codename}-proposed-updates'
    - geerlingguy.docker

  vars:
    packages:
      - git
      - vim
      - wget
      - curl
      - htop
      - tree
      - ncdu

  tasks:
    ###############################################
    # Base: Packages

    - name: Ensure a list of packages installed
      ansible.builtin.apt:
        name: "{{ packages }}"
        state: present

    - name: All done!
      ansible.builtin.debug:
        msg: Packages have been successfully installed

    ###############################################
    # Base: SSH
    # Manage here to avoid removal after cloud-init
    - name: Harden SSH config
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "{{ item.regexp }}"
        line: "{{ item.line }}"
        validate: sshd -t -f %s
      loop:
        - { regexp: '^#?PermitRootLogin', line: 'PermitRootLogin no' }
        - { regexp: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' }
        - { regexp: '^#?X11Forwarding', line: 'X11Forwarding no' }
      notify: Restart sshd

    ###############################################
    # Base: Firewall and Fail2ban
    # Manage here to avoid removal after cloud-init
    - name: Allow SSH through UFW
      community.general.ufw:
        rule: allow
        name: OpenSSH

    - name: Allow HTTP and HTTPS through UFW
      community.general.ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop:
        - "80"
        - "443"

    - name: Enable UFW
      community.general.ufw:
        state: enabled
        policy: deny

    - name: Configure rsyslog to redirect UFW logs
      ansible.builtin.copy:
        dest: /etc/rsyslog.d/20-ufw.conf
        content: |
          :msg,contains,"[UFW " /var/log/ufw.log
          & stop
        owner: root
        group: root
        mode: '0644'
      notify: Restart rsyslog

    - name: Ensure ufw.log exists with correct permissions
      ansible.builtin.file:
        path: /var/log/ufw.log
        state: touch
        owner: syslog
        group: adm
        mode: '0640'
        modification_time: preserve
        access_time: preserve

    - name: Set UFW logging level to low
      community.general.ufw:
        logging: 'low'

    - name: Install fail2ban
      ansible.builtin.apt:
        name: fail2ban
        state: present

    - name: Enable and start fail2ban
      ansible.builtin.service:
        name: fail2ban
        state: started
        enabled: true

    ###############################################
    # Base for Docker

    - name: Create base docker data path
      ansible.builtin.file:
        path: /docker
        state: directory
        mode: '0755'

    - name: Add user to docker group
      ansible.builtin.user:
        name: "{{ ansible_user }}"
        groups: docker
        append: true

  ###############################################
  # HANDLERS
  handlers:
    - name: Restart rsyslog
      ansible.builtin.service:
        name: rsyslog
        state: restarted

    - name: Restart sshd
      ansible.builtin.service:
        name: ssh
        state: restarted

Ansible playbook: Base upgrades

---
- name: Base Upgrade - System Update and Maintenance
  hosts: all
  become: true
  become_user: root

  vars:
    apt_cache_valid_time: 3600
    reboot_settings:
      connect_timeout: 5
      reboot_timeout: 300
      pre_reboot_delay: 0
      post_reboot_delay: 30
      test_command: uptime

  tasks:
    - name: Update APT package cache
      ansible.builtin.apt:
        update_cache: true
        force_apt_get: true
        cache_valid_time: "{{ apt_cache_valid_time }}"
      tags:
        - update
        - packages

    - name: Perform system upgrade
      ansible.builtin.apt:
        upgrade: dist
        force_apt_get: true
      tags:
        - upgrade
        - packages

    - name: Check reboot requirement
      ansible.builtin.stat:
        path: /var/run/reboot-required
        get_checksum: false
      register: reboot_required_file
      tags:
        - reboot
        - check

    - name: Perform system reboot if required
      ansible.builtin.reboot:
        msg: "Reboot initiated by Ansible due to kernel updates"
        connect_timeout: "{{ reboot_settings.connect_timeout }}"
        reboot_timeout: "{{ reboot_settings.reboot_timeout }}"
        pre_reboot_delay: "{{ reboot_settings.pre_reboot_delay }}"
        post_reboot_delay: "{{ reboot_settings.post_reboot_delay }}"
        test_command: "{{ reboot_settings.test_command }}"
      when: reboot_required_file.stat.exists
      tags:
        - reboot

Ansible playbook: Backup Ghost

Edit inventory/group_vars/all/vars.yml and set

# Backup
backup_retention_days: 365

playbooks/backup-ghost.yml

---
- name: Backup Ghost
  hosts: all
  become: true
  become_user: root
  become_method: ansible.builtin.sudo

  vars:
    backup_dir: /docker/backups
    backup_timestamp: "{{ ansible_date_time.date }}-{{ ansible_date_time.hour }}{{ ansible_date_time.minute }}{{ ansible_date_time.second }}"
    ghost_content_dir: /docker/web/ghost/content
    mysql_container: web-ghost-db-1
    mysql_database: ghost
    mysql_user: "{{ ghost_db_user }}"
    mysql_password: "{{ ghost_db_password }}"


  tasks:

    - name: Create backup directory
      ansible.builtin.file:
        path: "{{ backup_dir }}/{{ backup_timestamp }}"
        state: directory
        mode: '0750'

    ##############################################################
    # MySQL backup

    - name: Dump Ghost MySQL database
      community.docker.docker_container_exec:
        container: "{{ mysql_container }}"
        command: >
          mysqldump -u {{ mysql_user }} -p{{ mysql_password }}
          --single-transaction
          --routines
          --triggers
          {{ mysql_database }}
      register: mysql_dump

    - name: Write MySQL dump to file
      ansible.builtin.copy:
        content: "{{ mysql_dump.stdout }}"
        dest: "{{ backup_dir }}/{{ backup_timestamp }}/ghost-db.sql"
        mode: '0600'

    ##############################################################
    # Ghost content backup

    - name: Archive Ghost content directory
      ansible.builtin.shell:
        cmd: >
          tar czf {{ backup_dir }}/{{ backup_timestamp }}/ghost-content.tar.gz
          -C {{ ghost_content_dir }} .
      changed_when: true

    ##############################################################
    # Cleanup old backups

    - name: Remove backups older than {{ backup_retention_days }} days
      ansible.builtin.find:
        paths: "{{ backup_dir }}"
        age: "{{ backup_retention_days }}d"
        file_type: directory
      register: old_backups

    - name: Delete old backup directories
      ansible.builtin.file:
        path: "{{ item.path }}"
        state: absent
      loop: "{{ old_backups.files }}"

    ##############################################################
    # Summary

    - name: List backup contents
      ansible.builtin.find:
        paths: "{{ backup_dir }}/{{ backup_timestamp }}"
      register: backup_files

    - name: Show backup summary
      ansible.builtin.debug:
        msg: "Backed up: {{ item.path }} ({{ (item.size / 1024 / 1024) | round(2) }} MB)"
      loop: "{{ backup_files.files }}"

Migrating from an existing Ghost setup in Docker

My setup required a host-to-host migration from dnsmichi.at to dnsmichi.com. Here are the steps in case they are helpful:

Create a backup from dnsmichi.at host

cd /docker/ghost

docker exec ghost-ghost-db-1 mysqldump -u root -p ghost > ghost_backup.sql

tar czf ghost_backup_sql.tar.gz ghost_backup.sql

tar czf ghost_content.tar.gz /docker/ghost/content

Download the tarballs with scp.

scp root@dnsmichi.at:/docker/ghost/ghost_backup_sql.tar.gz .

scp root@dnsmichi.at:/docker/ghost/ghost_content.tar.gz .

Modify the database backup and replace the old domain with the new domain.

tar xzf ghost_backup_sql.tar.gz

sed -i s,dnsmichi.at,dnsmichi.com,g ghost_backup.sql

rm ghost_backup_sql.tar.gz
tar czf ghost_backup_sql.tar.gz ghost_backup.sql

Upload the backup to the new host.

scp *.tar.gz michi@michi.fyi:/tmp

On the new host, import the backup. Important: Stop the Docker compose stack before moving files in place.

cd /docker/web

mv /tmp/ghost_* .

docker compose down

mkdir -p /docker/web/ghost

mv docker/ghost/content ghost/

Keep hosts down, and import the database next, start only this container. Match the host to docker ps .

docker compose up ghost-db -d

docker exec -i web-ghost-db-1 mysql -u root -p<password> ghost < /docker/web/ghost_backup.sql

Bring the all containers back up (Ghost server, Traefik)

docker compose up -d

The old setup used Ghost 5. Before upgrading, I took a Ghost backup with the new Ansible playbook, and modified the group_vars ghost_version to 6 then.

Transparency note: With a little help from AI

Claude greatly helped me learn Traefik, debug gateway timeouts and incorrect configuration, and optimize the Ansible setup.

Here are a few prompts with great outputs to iterate faster:

I'm configuring GHost with Traefik in Docker. It cannot find the database host.

Next one - ACME letsencrypt problems

Thanks. I'm a beginner with Traefik in Docker (store that in your memory for later questions).

It works - now Traefik redirects to https and I see a gateway timeout error in browser and curl.

Now I need to add a placeholder page for michi.fyi as domain + TLS certificates. Probably best with Nginx as a container. What can you recommend using the current config? 

I used the attached config. How to update the Ansible playbook too? 

Works. Now comes the big part - I have an existing Ghost setup in Docker in production, and want to migrate everything to the new platform. I assume I need MySQL backups but also file upload storage. The Docker config looks exactly the same for Ghost, minus the Traefik labels (old setup used nginx automation)

Now for some Ghost updates ... I made the theme to "auto" and have a custom menu injection that still renders white on dark. 

And I also need to configure this redirect into TRaefik with Letsencrypt for dnsmichi.dev 

And finally, the old host keeps dnsmichi.at ... but should always redirect to dnsmichi.com, instead of running Ghost. Can you help me with a similar Nginx config? 

Now for some Ansible and Ubuntu best practices ... which other things come to mind? 

Does group_vars/all/vault.yml actually work?

Regarding latest image tag - should I changed that and pin it  for Traefik? 

Should I use Ansible vars in host_vars to specify the tags and use them in the jinja templates?

Traefik question - I also want to use the Traefik dashboard, but with password auth. How would I modify docker compose/Ansible for that?

The final step is now to run run.sh in Ansible, right? 

Can I force the run.sh behavior with the community module?

Last question on Ghost, promise. The TOC menu shows dark text on dark background. 

Agentic Memory

Claude was also a little direct in conversations, assuming my knowledge.

That's exactly what the error is telling you: resolver exists, but no challenge method specified.

So I used Agentic Memory for the first time: