Separation of concerns of VPS and Docker apps

Introduction

I’ve been working on a clean, reusable setup for running my own apps on a Virtual Private Server (VPS), specifically on Hetzner Cloud. The result is three open source repositories that take you all the way from bare metal to running Nginx, WordPress, and Nextcloud behind a proper reverse proxy, with automatic SSL certificates and a security baseline I’m actually happy with.

This post walks you through all three repos and how they fit together. For the app deployment part, I built it two ways: one using Ansible, one using OpenTofu. More on that in a bit.

Before

Earlier I combined the VPS and apps and wrote about it. This here is my definite setup though!

Be sure to setup SSH access and Hetzner firewall before running the VPS code.

  1. Setup SSH
  2. Setup Firewall

Architecture

Both app repos produce the same running stack:

Internet -> Traefik (ports 80/443, Let's Encrypt TLS)
             |-- Nginx static sites
             |-- WordPress (Nginx + PHP-FPM + MariaDB + Redis)
             `-- Nextcloud (app + cron + PostgreSQL + Redis)

Each app gets its own internal Docker network so containers can only talk to what they need to. Traefik handles all routing and certificate management via container labels.

The foundation

Before any app gets deployed, you need a server. infra-hetzner-vps-clean handles exactly that: provisioning a clean, hardened VPS on Hetzner with OpenTofu (the open-source fork of Terraform) and configuring it with Ansible.

The idea is simple: OpenTofu creates the server and generates secure random passwords, cloud-init does the bare minimum to get Ansible on it, and then Ansible takes over for everything else.

Server provisioning with OpenTofu

OpenTofu provisions the server and wires it all up:

  • Generates two 32-character random passwords (one for your admin account, one for the deployacc automation account)
  • Attaches your existing SSH key and Hetzner firewall
  • Sets up a reverse DNS (PTR) record
  • Passes everything to cloud-init via user data

The SSH key and firewall management are handled by separate companion repos (infra-hetzner-ssh and infra-hetzner-firewall), keeping concerns cleanly separated.

Cloud-init bootstrap

Cloud-init is the first thing that runs on a fresh server. It’s deliberately minimal here:

  • Create the two user accounts (admin + deployacc)
  • Disable root login and password-based SSH auth
  • Install Python so Ansible can connect

That’s it. The heavy lifting is left to Ansible.

Ansible security hardening

The Ansible security role configures quite a lot, for example:

SSH hardening

  • Key-only authentication, no root login
  • Modern ciphers only: ChaCha20-Poly1305, AES-256-GCM
  • 30-second login grace time, max 3 sessions
  • AllowUsers restricted to your admin account only

Network

  • UFW (Uncomplicated Firewall) allowing only ports 22, 80, and 443
  • Fail2Ban with 7-day bans and optional email alerts via a mail relay

Kernel hardening via sysctl

  • SYN cookie protection
  • IP spoofing protection
  • ARP hardening

Audit and access control

  • Auditd watches sensitive files: /etc/passwd, /etc/shadow, /etc/sudoers, SSH config, Docker socket, and cron
  • AppArmor for mandatory access control
  • Unattended security upgrades with automatic reboots

The common role rounds things out with a sensible package set (vim, htop, tmux, jq, and the usual suspects), proper timezone configuration, and a deploy user with sudo and Docker group membership.

If you’re planning to use the OpenTofu app deployment repo, you can also pre-create base directories for your apps here. The deploy account will own them, so app deployment won’t need sudo at all.

Quick start

Start by copying the example file to a live terraform.tfvars and fill in the blanks.

cp terraform.tfvars.example terraform.tfvars
# fill in your Hetzner API token, domain, admin email, and username
tofu init && tofu apply
# follow the printed instructions to set up Ansible Vault and run the playbook

Follow the README instruction and tofu outputs and it should be completely clear.

Deploying the Apps

Once the VPS is up and hardened, it’s time to deploy apps. I built two versions of the same setup:

Both deploy the same stack:

  • Traefik as a reverse proxy with automatic Let’s Encrypt SSL
  • Nginx static sites (with SPA support)
  • WordPress instances (with MariaDB, Redis, and WP-CLI)
  • Nextcloud instances (with PostgreSQL and Redis)

Everything runs in containers. Both repos are cloud-agnostic and work on any Docker host, not just Hetzner.

apps-docker-cyberbits-ansible

This repo uses Ansible roles to deploy and configure each part of the stack. The structure is clean:

  • Separate playbooks per app type (Traefik, Nginx, WordPress, Nextcloud)
  • Group vars files for app configuration
  • Ansible Vault for secrets

Secrets management

Secrets are generated by a setup script and stored encrypted in vault.yaml using Ansible Vault. This includes database passwords, WordPress auth keys and salts, Nextcloud admin passwords, and Redis passwords. The vault file is safe to commit; the .vault_pass file that unlocks it is gitignored.

If you provisioned the VPS with infra-hetzner-vps-clean, you can pull the deploy account’s sudo password directly from there:

tofu output -raw deployacc_sudo_password

Configuring apps

Apps are defined in YAML files under inventory/group_vars/. Adding a WordPress site is as simple as adding a block:

wordpress_apps:
  - name: blog
    domain: blog.example.com
    db_name: blog_wp
    db_user: blog_wp
    table_prefix: wp_

Then run ./ansible-vault-setup.sh to generate passwords for the new app, and deploy.

Nginx sites support two templates out of the box:

  • default: standard static site
  • spa: routes all requests to index.html for React, Vue, or Angular apps

Adding your own template is just a matter of dropping a new .j2 file in the role’s templates directory.

For WordPress, the role handles quite a bit automatically:

  • Creates the app directory structure
  • Deploys the docker compose file, Nginx config, and PHP config
  • Generates the .env file on first run (won’t overwrite an existing one)
  • Starts the containers
  • Sets up a cron job for WP-CLI to run scheduled tasks every 5 minutes

Deploying

Run the appropriate playbook.

ansible-playbook playbooks/deploy-traefik.yaml
ansible-playbook playbooks/deploy-all.yaml

Ansible prints a summary at the end with all URLs.

apps-docker-cyberbits-opentofu

This repo achieves the same result using OpenTofu’s Docker provider. Instead of Ansible roles with Jinja2 templates, you have OpenTofu modules with HCL and .tftpl templates. OpenTofu talks directly to the Docker daemon over SSH.

Secrets management

This is where the two approaches differ most. In the OpenTofu version, all secrets are generated automatically using random_password resources on first apply. You never touch them manually.

Secrets live in two places:

  • .env files on the server (written with 0600 permissions)
  • The OpenTofu state file (plaintext, treat it like a password store)

For production, use remote state with encryption, like S3 with server-side encryption.

Configuring apps

Apps are defined in terraform.tfvars. Adding a WordPress site:

wordpress_apps = [
  {
    name         = "blog"
    domain       = "blog.example.com"
    db_name      = "blog_wp"
    db_user      = "blog_wp"
    table_prefix = "wp_"
  }
]

Then tofu apply. Passwords are generated automatically for new entries.

The OpenTofu version also exposes fine-grained resource controls that the Ansible version doesn’t have out of the box. For each WordPress site, you can independently tune memory limits and CPU shares for:

  • PHP-FPM (the WordPress app container)
  • Nginx sidecar
  • MariaDB
  • Redis
  • WP-CLI

This makes it easier to dial in the stack for your specific server size.

Updating container images is also more surgical: tofu apply -replace=module.wordpress_apps["blog"].docker_container.wordpress recreates only the app container, leaving data volumes untouched.

Deploying

Start by copying the example file to a live terraform.tfvars and fill in the blanks.

cp terraform.tfvars.example terraform.tfvars
# set docker_host = "ssh://deployacc@your-server.com" and configure your apps
tofu init && tofu plan && tofu apply

Ansible vs OpenTofu

Both repos deploy the exact same architecture. The difference is in tooling philosophy and operational model.

AnsibleOpenTofu
LanguageYAML + Jinja2HCL + .tftpl
SecretsAnsible Vault (encrypted file)Auto-generated, stored in state
State trackingNone (stateless)Full state file
Adding an appEdit YAML, run vault script, run playbookEdit tfvars, run tofu apply
Updating imagesRe-run the playbooktofu apply -replace=...
Resource tuningLimitedPer-container memory/CPU controls
IdempotencyTask-levelResource-level
Learning curveLower for ops backgroundsLower for dev/IaC backgrounds
Sensitive data exposureEncrypted at rest in vaultPlaintext in state file (needs care)
Selective destroyManual docker commandstofu destroy -target=...

Go with Ansible if

  • You’re more comfortable with ops and config management tooling
  • You prefer explicit control over what runs and when
  • You want secrets encrypted in version control without managing remote state
  • You or your team already knows Ansible

Go with OpenTofu if

  • You think in terms of Infrastructure as Code (IaC) and declarative state
  • You want automatic secret generation without a setup script
  • You want fine-grained resource controls out of the box
  • You’re comfortable managing a sensitive state file (or already have remote state set up)

Personally, I built both because I wanted to explore the tradeoffs firsthand. For a solo homelab or small personal setup, either works great. For a team environment where you want audit trails and drift detection, OpenTofu has the edge (you’ll need your state moved to a central, secure location though). For something you want to hand off to someone who knows Ansible cold, the Ansible version is hard to argue against.

Wrapping up

The three repos fit together cleanly:

  1. infra-hetzner-vps-clean gives you a hardened, Docker-ready VPS
  2. Pick either apps-docker-cyberbits-ansible or apps-docker-cyberbits-opentofu to deploy your apps
  3. Add apps by editing a config file and running one command

Both app repos are cloud-agnostic and work on any Docker host. Mix and match as you like. All three are MIT licensed and live on GitHub.

Next

Something quite different. I’ve done and completed the Google AI certification. Next week I’ll summarize my notes, hopefully helping you out with doing the same, and at the least provide you with some (basic) AI knowledge.