Skip to content

Step 2 of 7 in the DevOps journey · see the full journey

2025

Automation Alchemy

Six virtual machines provisioned entirely by Ansible, including a Jenkins server configured as code.

Overview

I replaced repeated manual setup with Ansible roles and added a Jenkins pipeline for the diagnostic application. The work connects service configuration, container delivery and scheduled backups, with repeat provisioning as an explicit check.

context
Coursework
status
Archived learning project
role
Solo
started
May 2025
setting
Local six-VM lab with Ansible provisioning, Jenkins CI and containerised application deployment.
built with
  • Ansible
  • Jenkins
  • JCasC
  • Vagrant
  • Go
  • Node.js
  • +4

Key decisions

Organise service configuration into roles
Ansible roles and shared variables describe the VM services, while Jenkins Configuration as Code describes the CI server and its job setup.
Make unchanged provisioning observable
I used a second provision run ending in changed=0 as the target, then investigated tasks that reported unnecessary changes.
Manage published container ports at the correct firewall chain
Docker's forwarding rules bypassed the UFW INPUT rules I first expected to apply. Container-port controls moved to DOCKER-USER.

Demonstrated results

Repeat provisioning reached changed=0
The development record reports an unchanged recap after correcting file-state tasks, download guards and conflicting configuration.
Application delivery and backups were automated
Jenkins built and deployed the Go and Node application; Ansible installed client-side rsync scripts and cron jobs for a dedicated backup host.

Known limitations

VPN clients could still bypass the application proxy
The documented private-network access path was left open. Moving the WireGuard gateway onto a separate host was proposed, not implemented here.
Host routing and upstream webhook policy needed workarounds
The VPN experiments affected management traffic, and private-address webhooks were blocked upstream. These were environment-specific constraints on this lab.

The renumbering

I changed the host addresses so the old and new environments could coexist.

Project 1 used 192.168.56.10 for its app server. SSH recorded that host key in known_hosts. When this project started at the same address, SSH refused to connect:

Offending ECDSA key in /home/juho/.ssh/known_hosts:3
Host key verification failed.

The available fix was ssh-keygen -R. It was rejected in favour of renumbering the whole fleet to .20–.25, and the reason given is the useful part. It was not correctness, but the ability to run both environments at once:

I want to be able to easily use both projects VMs, so I'm just changing the ips in this project

Note the subnet did not move; only the host octets did. .20 was chosen as a block rather than as an offset. The journal's own phrase is "both projects VMs", so this is coexistence, not an inference.

The fix that broke other things

The development record includes a WireGuard routing problem that blocked delegated Ansible tasks whenever the host VPN was up, because traffic for the entire subnet was being sent to the gateway through the gateway.

Split tunnelling was attempted. It is recorded in capitals:

THIS IS NOT FIX!!!! BROKE OTHER THINGS!!!!

Browser access to services broke during that attempt. The journal later records allowing SSH from the VPN subnet on the backup server; the portfolio's issue record also identifies a host-side routing workaround. These are adjustments to this environment rather than a general VPN-routing solution.

Private-address webhooks were another external constraint. The school policy blocked them, so the recorded workaround used the public address to trigger CI.

Application delivery and backups

I split configuration into service roles with shared variables, and used JCasC for Jenkins configuration and job definitions. The pipeline built the Go backend and Node frontend images, ran linting and security tools, pushed images to Docker Hub and deployed the application.

For backups, I chose a push model after comparing it with a central pull process. Client roles install rsync scripts and scheduled jobs that send selected paths to the dedicated backup server. The roles manage the supporting user, directories and SSH keys. This describes the configured backup workflow; a later observability stage does not retain those active backup plays.

Container firewall rules

Docker's published ports use forwarding rules, so UFW's INPUT rules were not the control point I had assumed. The recorded configuration moves these rules into DOCKER-USER, before Docker's own forwarding rules. Rule placement matters alongside the VPN access limitation below; it is not a claim that every access path was closed.

Changed=0

I set a concrete target for repeat provisioning:

ensure the automation was perfectly idempotent, meaning a second run should result in changed=0 in the final playbook recap

The retrospective records several changes, including:

  • get_url with force: false "was found to be unreliable", replaced by a stat + block + when pattern across every role
  • state: touch always reports changed; state: file reports truthfully

The outcome is described as a "sea of green" ok statuses with changed=0.

Still open

Because the WireGuard gateway NATs traffic, an admin's VPN client can still reach the backend port directly in this setup. A separate gateway VM was proposed to make that boundary easier to enforce, but was not added to this archived project.

What this became

Sherlock Logs starts from this exact fleet: same machines, same Ansible layout, same load balancer. It spends the backup-server on a dedicated monitor instead. The backups described above are not carried over; the backup roles survive there as commented-out plays with an empty backup_paths, pointing at a host its Vagrantfile no longer defines.

Stack and source figures

how it fits together

configuration
  • Ansible Idempotent roles per host, applied from WSL over the host-only network. The same topology stage 1 built, now driven rather than hand-run.
  • JCasC Jenkins declared as code. Its credential lookups failed loudly once because a required credential was simply absent.
pipeline
  • Jenkins + dedicated CI VM Builds, lints and deploys the application that stage 1 had no way to serve.
security
  • Hadolint + Gosec Container and Go security scanning inside the pipeline rather than as a separate review step.
runtime
  • Docker Container images for the application tier. Published ports bypass UFW INPUT via the FORWARD chain, so the firewall rules for container ports live in DOCKER-USER.
frontend
  • Go + Node.js The application whose build and deploy this stage automates.
VMs
6
Ansible roles
12
commits
73
Full issue record · 9 entries

what broke, and what happened

  • 7 fixed / 9
  • 2 workaround / 9

Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.

  1. fixed

    An SSH host-key collision forced the entire address range to change

    `Offending ECDSA key in /home/juho/.ssh/known_hosts:3`. Host key verification failed.

    then: The six VMs moved from `.10–.15` to `.20–.25` rather than editing `known_hosts`, so the stale keys were sidestepped instead of trusted. That renumbering is why this stage and the one before use different ranges.

  2. fixed

    Vagrant from WSL again, this time over the port-forward Host IP

    VirtualBox bound the GUI port-forward rule to 127.0.0.1, so `config.ssh.host` could never connect. Fixed by defining the forwarded port per VM with `host_ip "0.0.0.0"`.

    then: The forwarded port is now declared per VM in the Vagrantfile with `host_ip "0.0.0.0"`. That is what makes provisioning from WSL possible at all.

  3. fixed

    An Ansible task used `group:` instead of `groups:`, silently destroying sudo membership

    `id devops` → `uid=1002(devops) gid=998(docker)`. The task overwrote the primary group rather than appending to it.

    then: Corrected to `groups: sudo` in the common role. The tell was `gid=998(docker)`: a supplementary-group mistake shows up in the primary GID because the wrong key replaced the whole group list.

  4. fixed

    A handler nested directly inside a `block:` was not found at runtime

    `ERROR! The requested handler 'Reload systemd and restart docker' was not found`

    then: Moved to the role-level `handlers/main.yml`. A handler declared inside a task block is not registered for the play at all, so Ansible never resolves it there.

  5. workaround

    WireGuard routing loop, and the fix that was worse than the bug

    `THIS IS NOT FIX!!!! BROKE OTHER THINGS!!!!`

    then: Resolved on the host, not in the repo: the VPN client no longer routes `192.168.56.0/24` into the tunnel, because that range is the same one the tunnel endpoint lives on. The journal heading is accurate. An earlier attempt made it worse.

  6. fixed

    Docker publishes ports through FORWARD, bypassing UFW INPUT entirely

    Docker's iptables rules for published ports take precedence in the FORWARD chain, bypassing UFW INPUT chain rules.

    then: UFW rules written for a published port were moving into the `DOCKER-USER` chain instead, so the firewall rules for container ports live there now. Docker consults that chain before its own.

  7. fixed

    A Grafana dashboard referenced a template variable UID that never resolved

    `Datasource ${DS_PROMETHEUS} was not found`

    then: The data source UID is now pinned in the role defaults (`grafana_prometheus_ds_uid: "P50D4F4AEAA7CC544"`), read out of the dashboard JSON rather than guessed.

  8. fixed

    JCasC took a long fight, ending when a required credential turned out to be simply absent

    `Warning: CredentialId "jenkins-gitea-id" could not be found`

    then: The credential is now declared in the JCasC config the pipeline job expects, so the lookup resolves on boot instead of warning and leaving the pipeline broken.

  9. workaround

    Webhooks from private IPs are blocked by school policy. It took about four hours to confirm that

    then: No code fix exists. The block is upstream of anything in the repo. Worked around by triggering the pipeline over the public address rather than the private one.