Skip to content

The Machine I Actually Work From

Every other post in this series is about a machine that runs a service: Jenkins, SonarQube, Nexus, the jump host that only forwards SSH. This one is about the machine I actually sit at. dev-machine-rootfs is a full DevOps workstation, more than 40 tools installed at build time, that boots ready in a browser tab instead of accumulating over a year of apt install on whatever laptop I happen to be using.

Try It Yourself

See the model boot for real, not in theory: Launch Dev Machine Playground ↗

Click Start, and every tool in the table below is on PATH in under a minute.

Not the Jump Host

The CI/CD stack has a machine called dev-machine too, and it is worth separating the two before they get confused. The stack's jump host boots from dev-cicd-rootfs: 1 vCPU, 1 GiB RAM, nothing installed beyond the base image and SSH aliases to the other three servers. It exists to give you somewhere to stand while you drive Jenkins, SonarQube, and Nexus.

This post is about a different image entirely. dev-machine-rootfs is the full workstation, a separate playground with its own manifest at 4 vCPU and 10 GiB RAM, sized for actually running builds, not just holding an SSH session open.

What's Actually In It

Forty-odd tools, all installed at build time. By category:

  • Languages and build tools: OpenJDK 21, Python 3.x with pip3 and venv, Node.js LTS, Maven
  • Containers and Kubernetes: Docker CE, kubectl v1.32, Helm, Kustomize v5.7.1, k9s v0.50.10, kubectx/kubens v0.9.5, stern v1.33.0, eksctl v0.226.0, skaffold, helmfile v1.5.2, helm-diff
  • Infrastructure and cloud: Terraform, AWS CLI v2, GitHub CLI
  • Configuration and automation: Ansible, ansible-lint, pre-commit, yamllint, act v0.2.89
  • Supply chain and security: Trivy v0.64.1, Gitleaks v8.28.0, cosign v3.0.3, syft v1.26.1, hadolint v2.12.0, dive v0.13.1, Skopeo
  • General-purpose CLI: jq v1.8.1, yq v4.46.1, fzf v0.65.2, ripgrep v14.1.1, lychee v0.24.2, nmap, socat, cloudflared
  • Database clients: mysql-client, postgresql-client, sqlite3, redis-tools, mongosh

I pin an exact version and a GitHub release for most of them. A handful (Docker CE, Terraform, Helm, AWS CLI, and whatever apt ships for the runtimes) track "latest" from an official repo or installer script on purpose, because pinning those means committing to maintaining the pin, and I would rather spend that attention on the tools where the version actually matters.

The install is one script, install-tools.sh, split into 35 numbered phases, one tool or group per phase, ending on cleanup. There is a second script beside it, install-tools-all.sh, with a wider and more experimental catalogue, and it is deliberately not wired into the build. I keep it as a staging area for things I am still evaluating. Worth flagging, because a script that looks like it should run and does not is exactly how an image ends up shipping more or less than you think it does.

Aliases That Are Actually Wired to Completion

The customize-bashrc.sh script installs five groups of shell aliases:

# kubectl
k kgp kgs kgn kgd kaf kdf kdp kns kctx klog kexec

# docker
d dps dpsa di dex dlog dprune dc dcup dcdown dclogs

# terraform
tf tfi tfp tfa tfd tfv tff

# git
g gs ga gc gp gl gco gb gd

# general
ll la .. ... ports myip paths

The detail that makes these more than a shortcut list: tab completion is installed system-wide under /etc/bash_completion.d/, and the short aliases are wired to the same completion functions as the full commands.

k get <TAB>      # identical to: kubectl get <TAB>
d run <TAB>      # identical to: docker run <TAB>

An alias that saves four keystrokes but breaks completion is a net loss the first time you reach for it on a resource name you cannot type from memory. Wiring k to kubectl's own completion function means the alias costs nothing.

Why cloudflared Is In That List

Same constraint as every other machine in this stack: the playground sits behind NAT with no public IP on the VM. I put cloudflared in for exactly that reason, so I can expose something local, a web app I am mid-way through or a quick API, through a Cloudflare Tunnel without any port forwarding.

It is one line in a forty-tool list and easy to read past. It is also the only tool here whose presence is dictated by the platform rather than by what a DevOps toolbox usually holds.

Why the Dockerfile Ends with USER $USER

The Dockerfile ends on USER $USER rather than USER root, and that trips people up often enough to be worth spelling out.

The USER directive controls exactly one thing: which user docker run starts the process as. Here that has a single purpose, the binary-presence check. Running it as ibtisam instead of root proves every tool is on PATH and executable for the non-root user who will actually be typing these commands. End the file as root and that check still passes even if something landed somewhere only root can reach, which is precisely the failure it exists to catch.

For the microVM boot it makes no difference whatsoever. The platform mounts the filesystem as a block device, boots it with its own kernel, and systemd takes PID 1 regardless of what the OCI config says. The USER field is never read.

So the line is load-bearing in one context and inert in the other, which is why it looks like a mistake and is not.

The same asymmetry runs through the service images: USER $USER here and on dev-cicd-rootfs, USER root on Jenkins, SonarQube, and Nexus. Those three need root at check time to prove a service directory is writable before systemd ever takes over. Different validation needs, different endings.

Docker's Daemon Is Enabled, Not Started

docker.service gets enabled during the build and does not start there. docker build never runs a daemon inside the layer being built; enabling a unit just writes a symlink that systemd reads on the next real boot. On this image that boot is when the microVM starts, which is also the moment docker.service comes up and docker ps starts answering.

Small and easy to miss, and the same split that runs through the image model post: some things belong in the image, others only mean anything once a kernel is running underneath. Enabling a service is an instruction about what should happen at boot. It is not the service being up.

Verifying It Booted, Not Just Built

docker run against this image validates presence, not behavior: every tool binary exists and is reachable by the non-root user. It cannot tell you whether docker.service starts cleanly, whether tab completion actually loads, or whether the welcome banner behaves. That only shows up after a real boot:

labctl playground create --base flexbox dev-machine -f dev-machine.yml
systemctl is-active docker
docker ps
k get pods -A

If those pass, the workstation is not just built correctly, it is running correctly, which is the distinction this whole series keeps coming back to.

Source


Series: Building a Self-Hosted CI/CD Stack from Scratch (Part 3 of 6)