> ## Content Index
> Fetch the complete content index at: https://portfolio.cedric-bauer.ch/llms.txt
> Use this file to discover other available public pages before exploring further.

# Setup Ghost with Docker, Ansible and Github Actions
- URL: https://portfolio.cedric-bauer.ch/setup-ghost-with-docker-ansible-and-github-actions/
- Published: 2026-09-12T09:45:29.000Z
- Updated: 2026-09-12T09:54:55.000Z
- Author: Cedric Bauer
- Tags: CI/CD, Linux, Ansible, Docker, DevOps

As I mentioned in the [Automated App creation using ArgoCD and Kubernetes](https://portfolio.cedric-bauer.ch/fully-automated-kubernetes-deployment/) article the portfolio page you are visiting right now is no longer running on the kubernetes setup because the setup was to pricy for its usage. I moved the setup to a smaller host and run the ghost application now via docker. As I could no longer use ArgoCD to manage the application, as argocd is a Gitops controller for Kubernetes and the new setup should be running docker to safe resources, I switched to Ansible. In this article I will describe the application setup of my portfolio page and how I manage it via Ansible. 

## Host Setup

On my previous Kubernetes setup I had a VM at Informaniak with 4 CPU Cores and 12 GB of RAM. There were a few other things running on that host which I no longer need. I tested the setup locally with a VM to figure out which VM Specs would suffice for my setup. After some testing I chose following specs: 2 CPU Cores and 2 GB of RAM. With that setup ghost and a small VPN instance could run smoothly and still have some headroom. The new vm still runs at Informaniak but now with the new specs.

 First I wanted secure access to the VM so in the end the only ports that are open are 80/443 and the Wireguard Port 51820\. I setup "wg-easy", a docker setup for wireguard with a webui for configuration. Now I could connect via SSH over the VPN and could close port 22\. 

![](https://portfolio.cedric-bauer.ch/content/images/2026/09/portfolio-ansible-arch.png)

Then I set up an nginx reverse proxy to secure the admin web interface of wg-easy. I enabled HTTPS and restricted access so only clients coming from the VPN could reach it. This was done by running nginx with `network_mode: service:wg-easy`, so it shares wg-easy's network namespace entirely rather than having its own IP, and by closing off the direct port forwarding for the web UI on port 51821\. Because nginx shares wg-easy's namespace, it binds to every interface available there — including `wg0`, the WireGuard tunnel interface, which gets the address `10.8.0.1`. wg-easy's own web UI, meanwhile, is bound only to `127.0.0.1`, so it's reachable from nginx over loopback but not from any external interface. So an admin connected to the VPN can reach `https://10.8.0.1:8443`, where nginx terminates TLS and proxies the connection to `127.0.0.1:51821`. 

![](https://portfolio.cedric-bauer.ch/content/images/2026/09/carbon-23.png)

This would lock me out if I had no more clients to connect but via Informaniak for emergencys I can open a VNC session for debugging. 

Then I initially configured the the ghost setup from hand via the [official docks](https://docs.ghost.org/install/docker?ref=portfolio.cedric-bauer.ch). The docker compose Ghost setup contains a mysql DB, Ghost itself and a Caddy Proxy to acquire a lets encrypt certificate and provide SSL termination. The setup is simple, you one time configure a `.env` file for Domain, Ports, DB Passwords and Email configuration and start the containers. 

## Ansible Collection

Once I had the setup running so far I started building the collection. The collection contains two roles, one called `apt` to install the required packages and one called `docker` which configures and sets up the docker compose setup. I wanted to make the collection reusable so I made almost everything configurable. The `apt` role installs the `docker.io` and the `docker-compose-V2` packages. For this the distro apt repo is used for simplicity:

![](https://portfolio.cedric-bauer.ch/content/images/2026/09/carbon-25.png)

The `docker` role then completes the tasks to setup ghost with caddy. It also contains two Jinja templates, one for the .env file and one for the Caddyfile. The steps in the role include:

- Create install location
- Clone the ghost repository to that location
- Template the two jinja files
- Start the docker compose setup

![](https://portfolio.cedric-bauer.ch/content/images/2026/09/carbon-29.png)

The whole configuration happens in the two templates files. The `.env` file configures following:

- Domain
- if configured separate admin domain
- http and https port
- database user and root password
- Mail server credentials
- file upload location in the container
- mysql data location in the container

The Caddyfile only configures, if enabled, the proxy part of a separate admin webui domain. To secure the database passwords I encrypted them in `group_vars/all/vault.yaml`. 

As the ghost or mysql container, depending on what changed, needs to be recreated when the `.env` file changes and the caddy server needs to be reloaded when the `Caddyfile` changes I implemented a notify directive for both template tasks which each calls a specific handler in `handlers/main.yaml`:

![](https://portfolio.cedric-bauer.ch/content/images/2026/09/carbon-30.png)

My finished inventory looked something like this:

![](https://portfolio.cedric-bauer.ch/content/images/2026/09/carbon-28.png)

I then tested the playbook locally on a vm. I had some issues with the newest ubuntu server edition (26.04.1 LTS) which was running on the vm. This new version switched for the default `sudo` implementation from `sudo.ws` to `sudo-rs`. When using `become: true` in the playbook to execute tasks with elevated privileges ansible ran sudo with a `-p "<custom prompt>"` flag to then afterwards reliably detect when the password prompt appears. The new `sudo-rs` wraps this custom prompt like following example from the respective [GitHub issue](https://github.com/ansible/ansible/issues/85837?ref=portfolio.cedric-bauer.ch) shows: `[sudo: [sudo via ansible, key=spnnznrgscgdxdontsjkwrlyghiiglxk] password:] Password:`. This means ansible can not determine if the prompt actually showed up und returns following timeout error: `[ERROR]: Task failed: Timeout (12s) waiting for privilege escalation prompt:...`. I spent quite some time debugging this issue as I thought something in my setup was wrong. After using another MacBook and a new vm with the same ISO installation file I then started researching if this is a ubuntu bug as I reproduced the same issue here and found no config problems in my collection. So I found the earlier named issue. For now the workaround I use is to just add following line user the `all.hosts.myhost` in the ansible inventory: `ansible_become_exe: sudo.ws`. This fix sidesteps the compatibility issue but drops back to the C implementation until Ansible supports sudo-rs's prompt behavior. 

I still have a small linux host at home on which I running a few smaller services. I setup ran the installation script for the Github runner on this host. As the runner will have to execute the playbook via ssh and port 22 and this port on the traget host is only reachable through the VPN, I setup the wireguard client and configured a always up vpn profile which routes connections to the public IP of the targethost through the vpn. I placed the ansible vault decryption password at `/etc/ansible/.vault-pass` and restricted access so only the user with which the GitHub runner runs could access it. In a production environment I would use a secrets manager for this and not a static file. In the collection repository the `.github/workflows/deploy.yaml` references the self hosted runner and the vault decryption password:

![](https://portfolio.cedric-bauer.ch/content/images/2026/09/carbon-27.png)

So now whenever I merge a branch into `main` this workflow gets executed, which provisions the job on Githubs side. The runner in my home network checks periodically for new jobs and once he sees one he clones the repo and executes the `ansible-playbook` command from the working directory `cba_personal/ghost_portfolio`. As connections which target the public IP of my VM at informaniak are tunneled through the vpn and are then resolved locally on the vm the ssh connection succeeds. The last thing to do was add the public key of the runner to the `~/.ssh/authorized_keys` file on the target machine to enable key authentication. After some smaller fixes the setup worked so far:

![](https://portfolio.cedric-bauer.ch/content/images/2026/09/image.png)

Github Actions deploy jobs

![](https://portfolio.cedric-bauer.ch/content/images/2026/09/image-1.png)

Github Actions deploy job output

The last thing I then had to do was copy the images from the pv on the old kubernetes host: `kubectl cp <namespace>/<pod-name>:/var/lib/ghost/content/images ./ghost-pv`, then tar them, copy them to the new host and import them into the container: `sudo docker cp ghost-pv/. ghost-ghost-1:/var/lib/ghost/content`. The posts could be exported in the ui as a `.json` but the images sadly could not be exported from the ui. Still like this I could restore all articles. 

The final result is the page you are visiting right now :)

On my ToDo for this setup are now next:

- Backup Content of Ghost
- Monitor instance for uptime and container issues as well as storage usage
- Implement a secret store to server secrets in my home network for all kinds of setups