Continuous integration, or CI, asks the same useful questions whenever code changes: does it build, do its checks still pass, and did a change break something the last one did not? Woodpecker’s introduction explains the usual shape. The name makes it sound larger than it is. A CI pipeline is a repeatable way of finding out whether a proposed change holds up before it becomes someone else’s problem.
I edit pipelines at work often enough to know what one looks like once it is already there. I had never stood one up from nothing, or decided the boundary it should hold after it begins running repository code.
A Checker Is Not an Operator
The first rule was simple: a pull request can verify, but it cannot promote. It may install dependencies, format, lint, analyse, test, or build, depending on what the repository needs. It receives no production credential, no migration authority, and no deployment step.
A Quartz garden needs a build and a check that private notes did not leak into its output. An application needs a different set: types, tests, a build, and service health. Put each check beside the failure it should catch, then make it run before the change is trusted.
That boundary is a small version of least privilege as I understand it: give a piece of automation enough authority to answer its question, and no authority that belongs to a later decision.
Move the Build, Not the Risk
Repository workflows execute code. Running that work on the same machine that serves applications used to mean a failed fence could become a production problem. The old CI agent could write to the host Docker socket. On that host, that was effectively broad control over the running services. Once the separate runner had passed its proof, I removed the old agent.
The build now happens on a separate NixOS machine. I like it for this job because the runner is declared configuration I can review and rebuild rather than a sequence of remembered server changes. It connects outward to the CI coordinator over TLS; the build host exposes no incoming CI listener. If that machine is unavailable, jobs queue rather than falling back to the old arrangement.
This is not a claim that an outbound connection makes a machine safe. It removes one exposed listener and keeps a repository workflow away from the runtime host. Accepting a queue is the cost of refusing the easier but wider authority.
The runner uses a dedicated identity, rootless containers, and one workflow at a time. That last limit is not about making one job special. It makes the first boundary easier to observe: one thing is running, or nothing is.
Look at the Fence
I paused a small test workflow and inspected the container instead of treating the configuration as proof. It had one CI-managed workspace volume. It had no host bind mounts, Docker socket, devices, added capabilities, privileged mode, or production and deployment credential names in its environment.
That is the observation. The limit is just as important: it removes an easy route from a workflow into the runtime host. It does not make the CI coordinator or the images it provides trustworthy, and rootless containers are not a complete sandbox. Docker’s explanation of file-sharing boundaries is useful here because a mount is not a detail. It decides what part of the host a container can see.
Do Not Test Only the Happy Path
Moving the checks to another machine would have meant little if I had only watched a green run. The pilot kept the same contract it already had: install, analyse, test. It passed. Then one deliberate failing assertion made the next run stop with a legible non-zero result. Removing that assertion made the following run pass again.
The observation is narrow. The pipeline can distinguish a passing test from a failing one and show the failure clearly enough to act on. Dijkstra put the larger limit plainly in 1970: “program testing can be used very effectively to show the presence of bugs but never to show their absence.” The deliberate failure checks that this pipeline can show a problem. It does not make the suite proof that no other problem exists.
Let Consequence Set the Fence
Once the pipeline could check code without reaching production, the next question was not “how do I automate deployment?” It was “what changes if this promotion fails while I am not there?”
Static publication was the first answer. It runs only after a successful production-branch build and that site’s own checks. Each target has a separate restricted credential. The Apothecary adds a publication-boundary check because the relevant failure is not a broken page but private material escaping into a public one.
An application has more state to lose, so its promotion path is smaller again. The build produces one immutable image digest. A root-owned runtime mechanism accepts only that exact artifact. It backs up and applies a migration only when the migration manifests differ, checks the application and its warmer, and restores the previous application and warmer images if readiness fails. The database listens on loopback only.
Before any automated promotion was enabled, I restored an encrypted Forgejo archive inside a temporary isolated environment. The restored database, repositories, and maintenance checks passed; the temporary plaintext was then removed. That earns a smaller statement: that archive restored successfully on that day. It does not establish a restore cadence or make recovery finished.
The practice I want to keep is not “automate more.” It is to make the authority match the consequence, test the failure route as well as the success route, and leave the limits visible when they have not yet earned a cleaner story.