DevOps & Deployment

From Empty Repo to Live App With Your First CI/CD Pipeline

A deployment that returns HTTP 200 after a restart can still interrupt users during the restart. I would build a blue-green switch before trying a canary rollout, because it gives a junior developer one observable failure boundary: either the new instance is ready, or traffic stays on the old one. Start on your laptop, where a bad switch costs nothing and rollback is a command you can practice.

A small local switch teaches more than a deployment diagram

The first working setup needs three processes: an old application, a new application, and a proxy that decides which one receives requests. It does not need Kubernetes. Use Python 3.12 to serve two distinct pages and Caddy 2.8 or newer as the proxy. Python’s static server is only a stand-in for an application; it lets you verify the traffic switch without confusing deployment behavior with application startup bugs.

Install Python, Caddy, and curl, then run these commands in a shell from a disposable working directory. Ports 9001 and 9002 hold the blue and green versions; port 8080 is the address your test client will keep using.

mkdir -p release-lab/blue release-lab/green
printf 'blue-v1\n' > release-lab/blue/index.html
printf 'green-v2\n' > release-lab/green/index.html
python3 -m http.server 9001 --bind 127.0.0.1 --directory release-lab/blue >/tmp/blue.log 2>&1 &
python3 -m http.server 9002 --bind 127.0.0.1 --directory release-lab/green >/tmp/green.log 2>&1 &
printf ':8080 {\n reverse_proxy 127.0.0.1:9001\n}\n' > release-lab/Caddyfile
caddy run --config release-lab/Caddyfile --adapter caddyfile >/tmp/caddy-lab.log 2>&1 &
curl -fsS http://127.0.0.1:8080/
printf ':8080 {\n reverse_proxy 127.0.0.1:9002\n}\n' > release-lab/Caddyfile
caddy reload --config release-lab/Caddyfile --adapter caddyfile
curl -fsS http://127.0.0.1:8080/

The first curl response should be blue-v1; the second should be green-v2. The client URL never changes. To rehearse rollback, put port 9001 back in the Caddyfile, reload Caddy, and request the same URL again. Caddy’s reload avoids deliberately stopping the proxy, but this tiny exercise does not prove that a real application has no interrupted requests: the static pages have no database writes, sessions, or long-running responses.

That distinction matters. A successful response after the switch proves routing, not readiness. Before moving on, deliberately break green by stopping its Python process, point the proxy at it, and observe the failed request. Restore blue immediately. You now have a failure you can recognize rather than a release procedure that works only when every step goes well.

Readiness must block the switch, not merely report a problem afterward

Replace the static servers with two instances of your own application, preferably built from one immutable Docker image tagged with its Git commit SHA. Run the current image on one local port and the candidate image on the other. Keep their configuration equivalent except for the image version; otherwise, a passing test cannot tell you whether the new code or a changed environment produced the result. Docker Compose is sufficient for a first single-host rehearsal because it gives each instance a repeatable image and environment without adding a cluster control plane.

Add an HTTP /healthz endpoint that returns success only when the process can serve a basic request. Use a separate readiness check for dependencies the request actually needs. For example, if every page reads PostgreSQL 16, a failed database connection should block promotion; if an optional analytics service is down, making it fail readiness could remove an otherwise useful application from service. Keep the distinction explicit in code and in the release script.

Set curl’s –max-time to 2 seconds as an initial timeout to tune against your application’s normal latency. Probe the candidate repeatedly before changing the Caddy upstream, and fail the release if any required check fails. A single successful probe is weak evidence because it can land between a successful startup response and a later crash. Record the image SHA and the probe results so that “green passed” identifies a particular build, not whichever container happens to occupy the port.

For a live service, generate the next Caddyfile separately, validate it with caddy validate, then replace the active file and reload. The lab writes the file directly to keep the commands readable; I would not use that write-in-place step in production because a failed or partial update could leave the next reload with an invalid configuration. Keep the previous valid file available as a rollback artifact.

A broad deployment checklist (DevOps Deployment Best Practices for Faster Releases) can supply further checks, but I would add each gate only after naming the failure it prevents; gates with no clear purpose make a release slower without making its result easier to trust.

Blue-green wins the first release; canary wins when partial exposure is useful

Blue-green and canary are different bets. With blue-green, you bring up the candidate beside the current version, verify it, and switch the proxy. It wins when traffic is modest and you need a simple decision and fast reversal. Its cost is enough capacity to run two versions during the release, plus the risk that a fault appearing only under real traffic reaches everyone after the switch.

With a canary, you route a portion of traffic to the candidate while the current version continues serving the rest. It wins when you have enough requests to compare outcomes and a failure could be expensive at full exposure. Its cost is more routing configuration, longer observation, and metrics that can distinguish versions. A suggested initial share of 10% is a value to adjust to your traffic volume and risk: on a quiet service, that share may produce too few requests to reveal anything.

A catalog of rollout routes (Zero-Downtime Deployments with Blue-Green and Canary) is useful for comparing those choices, but it does not make canary the better first implementation. I would not begin with weighted routing because a junior developer who cannot yet prove a full traffic switch and rollback has more states to debug, not a safer deployment.

There is also a database constraint that neither routing method removes. Blue and green may run at the same time against the same PostgreSQL schema. Add a nullable column before code starts writing it; deploy code that tolerates both old and new records; remove the old column only in a later release. Running a destructive migration at the moment of the proxy switch defeats rollback because the old code may no longer understand the data. Test that sequence against a disposable database before trusting it with production records.

Once a blue-green release is routine, a canary can be a deliberate next experiment. An NGINX Ingress Controller can route a configured percentage to a canary backend in a Kubernetes setup, while Prometheus can separate HTTP error rates by version. That combination is useful only if you can identify which version served each request; an aggregate error graph can hide a failing candidate behind a healthy majority.

A release is repeatable only when its rollback has been exercised

Move the manual steps into a GitHub Actions workflow after the local drill succeeds. Have the workflow run npm ci and the application’s tests for a Node.js service, build the Docker image, push it to GitHub Container Registry under the commit SHA, and deploy that exact tag to the idle slot. Do not deploy a mutable latest tag, because it makes the version behind a rollback command ambiguous. Protect deployment credentials in the CI environment and require approval for the production job if your team uses one.

The deployment job should start the candidate, probe its readiness endpoint, run a small HTTP smoke test through the candidate’s direct port, validate the proposed proxy configuration, and only then reload Caddy. Keep the previous container running until the new version has served traffic successfully. For the first rehearsal, make 60 seconds your observation window and tune it after inspecting real startup and failure patterns; that interval is a practice setting, not evidence that delayed failures cannot occur.

Measure the result from the client side. Run repeated curl requests against the proxy during the switch and count failed requests, unexpected response bodies, and timeouts. Save Caddy access logs and tag application logs with the image SHA. Prometheus request counts and HTTP 5xx rates can support the same check on a service with traffic, while an OpenTelemetry trace can help explain a slow request that spans the release. A target such as zero failed requests in a local drill is a criterion for that drill, not a claim that every future request will succeed.

Finally, force a bad release in the rehearsal environment. Have the candidate return an error on a smoke-test route, confirm that the workflow refuses to switch, then create a failure that appears after switching and practice restoring the previous Caddyfile. Write down who decides to roll back and which command they run. A rollback plan that has only been read can contain the same typo as a deployment plan that has only been read.

Create the two-page lab first and keep its proxy URL constant while you switch versions twice: once forward, once back. Then substitute your application’s two builds and repeat the drill with a failed readiness check. Save the commands and observed responses in your repository. That small record gives your next release a concrete starting point—and gives a teammate something they can run rather than merely trust.