DevOps Pipeline Explained: From Git Push to Production
A DevOps pipeline explained on a real site: how one git push is checked, built into a Docker image, run on a VPS and served through nginx and Cloudflare.
By Matthews WongReviewed on
Key takeaway
A DevOps pipeline is the automated path a code change takes from a developer's laptop to production. On this site, a git push starts GitHub Actions, which checks the code, builds a Docker image and stores it in a registry; a VPS then pulls and runs that image, and visitors reach it through Cloudflare and nginx.
You will be able to
- Name every stop between git push and a page in a visitor's browser, and what each one does.
- Tell build time from run time, and say where configuration and secrets enter.
- Read a response's headers and say which layer of the stack produced them.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
From git push to a page in the browser
A push runs verify, then build-and-push, which stores an image in the registry, then deploy, which makes the VPS pull and run it; visitors reach the app through Cloudflare and nginx.
Keys: ← → step · Home / End · K play or pause
One push, two halves. GitHub Actions turns a commit into a Docker image; the VPS runs it, and visitors reach it through Cloudflare and nginx.
A push to main is the event that starts the workflow. On a fresh runner, the verify job installs dependencies, typechecks and lints.
on: push: branches: [main]build-and-push needs verify, so it runs only when the checks pass. It builds the image and pushes it to GHCR, tagged latest and with the commit SHA.
needs: verifyPredict the next step Where do the app's secrets enter on the way to production? (Baked into the image at build time · Written to .env on the server)deploy needs build-and-push. It connects to the VPS over SSH and writes the runtime settings into .env from GitHub secrets; nothing secret is baked into the image.
chmod 600 .envThe server pulls the new image, and docker compose up -d replaces the running container with one made from it. That swap is the deploy.
docker compose pull docker compose up -d --remove-orphansA visitor's request reaches Cloudflare first, then nginx on the VPS, which terminates TLS and forwards it to the container listening only on localhost.
# Bind to loopback only — nginx terminates TLS and proxies to this port.Predict the next step Next, a hashed JavaScript file is requested. Who answers it? (Cloudflare, from its cache · nginx on the VPS · The Next.js container)Hashed static files, like JavaScript chunks, come straight from Cloudflare's cache. HTML always goes to the server, by choice: the same URL can also answer in Markdown.
cache-control: public, max-age=31536000, immutable cf-cache-status: HIT
What is a DevOps pipeline?
DevOps is the practice of getting code from a laptop to production reliably, and keeping it running there. A pipeline is the automated part of that practice: a fixed chain of steps that every change goes through, in the same order, with nobody copying files to a server by hand. CI, continuous integration, checks each change. CD, continuous delivery or deployment, ships the change that passed.
Read more (1)Show less
On this site the pipeline is one GitHub Actions workflow, which GitHub defines as a configurable automated process that runs one or more jobs. A push to the main branch is the event that starts it, and each job runs on a runner, a server that executes the workflow. Jobs run in parallel by default, so the order comes from needs: build-and-push needs verify, and deploy needs build-and-push. If a job fails, every job that needs it is skipped.
What is the difference between build time and run time?
Build time happens once per commit, on GitHub's runner. The verify job typechecks and lints. Then build-and-push turns the code into a Docker image, a read-only template that holds the built app and everything it needs to run, and pushes it to GitHub Container Registry at ghcr.io. Every image is tagged twice, latest and the commit's SHA, and the deploy runs the SHA tag, so you can always say which commit is live.
Read more (1)Show less
Run time happens on the VPS. The deploy job connects over SSH, writes a .env file from GitHub secrets and variables, and has Docker Compose pull the new image and replace the running container with one made from it. Nothing secret is baked into the image, so the same image could run anywhere. The price is that GitHub becomes the source of truth for configuration: the server's .env is rewritten on every deploy.
How does a visitor's request reach the app?
The serving path runs the other way. The site's DNS record is proxied by Cloudflare, so a browser that looks up the name gets a Cloudflare address, not the server's. Cloudflare's edge either answers from its cache or forwards the request to the server. There, nginx terminates TLS and passes the request to the container, which is published on 127.0.0.1 only, so nothing outside the machine can reach the app directly.
Read more (1)Show less
Each layer leaves a trace in the response headers. server: cloudflare means the response passed through Cloudflare. cf-cache-status says what the edge did: HIT means it came from Cloudflare's cache, and DYNAMIC means the request went to the server without a cache lookup. On this site a hashed JavaScript file comes back HIT, with a one-year immutable cache header, while the HTML is always DYNAMIC, because the same URL can also return Markdown to clients that ask for it.
What does the rest of this DevOps course cover?
Each stop on the map gets its own lesson. First the Linux server itself: logging in with an SSH key instead of a password, the firewall, and which ports should be open. Then three lessons on Docker: how an image is built in layers, how a multi-stage build keeps it small, and how Docker Compose runs it in production with a memory limit, a health check and a restart policy.
Read more (1)Show less
After that comes the GitHub Actions pipeline in detail: the verify, build-and-push and deploy jobs, and where secrets live. Then nginx, TLS and Cloudflare, with the rules for what may be cached at the edge and what never should be. The course ends on one server's safety net: monitoring, rolling back a bad release, and backups.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
name: Deploy to VPS
# Build a Docker image, push it to GHCR, then deploy it to the VPS over SSH.
# All app configuration is read from GitHub at run time:
# Secrets → GROQ_API_KEY, RESEND_API_KEY, VPS_SSH_HOST, VPS_SSH_USER, VPS_SSH_KEY
# Variables→ CONTACT_FROM_EMAIL, CONTACT_TO_EMAIL, APP_PORT, DEPLOY_PATH, VPS_SSH_PORT
on:
push:
branches: [main]
workflow_dispatch:The trigger: a push to main, or a manual run from the Actions tab (workflow_dispatch). The header lists only the names of the secrets and variables the pipeline reads at run time. Their values live in the repository's GitHub settings, never in the code.
deploy:
name: Deploy to VPS
needs: build-and-push
runs-on: ubuntu-latest
# The one group that must NEVER cancel in progress. This step SSHes to the
# VPS and runs `docker compose pull && up -d`; interrupting it can leave the
# box between images. Concurrent deploys queue and run one at a time, which
# is the original intent — it is only the build chain above that should be
# throwing away superseded work.
concurrency:
group: deploy-vps
cancel-in-progress: falseneeds: build-and-push is what makes deploy wait, and build-and-push declares needs: verify the same way. The concurrency group is the other half of the design: a newer push cancels a stale build, but a deploy that has started is never interrupted, because stopping it halfway could leave the server between two images.
# Runtime compose used on the VPS. The image is built + pushed to GHCR by
# .github/workflows/deploy.yml; this file is copied to the server, and CI
# writes a sibling .env (from GitHub Secrets/Variables) that supplies both the
# ${IMAGE}/${APP_PORT} substitutions below and the container's runtime env.The contract between build time and run time, in four lines. CI builds the image and copies this file to the server, then writes a .env beside it. Compose reads that one .env twice: for the image tag and port in this file, and as the container's environment.
ports:
# Bind to loopback only — nginx terminates TLS and proxies to this port.
- "127.0.0.1:${APP_PORT:-3002}:3000"The app listens on port 3000 inside the container, and the host publishes it on 127.0.0.1 only. nginx on the same machine can reach it; the internet cannot. Without the 127.0.0.1 prefix, Docker binds to all interfaces, which can expose the container directly to the internet.
Mistakes that cost real time
Judging a deploy from a cached copy
A polling loop with a plain curl can keep getting a copy from before the deploy. On 2026-09-24 that led to a wrong conclusion here: the site looked as if it had rolled back when the new build was already serving. Add a random query string to every check, and read cf-cache-status before you trust what you see.
A hand edit on the server disappears
The deploy job rewrites .env from GitHub secrets on every run, so a value someone typed into the server's .env is gone at the next push. Change configuration in GitHub's settings, then deploy. A running container also keeps the environment it started with until it is recreated.
Secrets baked into the image
An API key written into the image at build time travels with every copy of it: the registry, every server that pulls it, every laptop that runs it. This pipeline keeps secrets out of the image entirely and writes them on the server at run time.
What I'd change here: wait for the health check
docker compose up -d returns as soon as the container starts, before its health check has passed. So a release that starts and then fails its health check still leaves the workflow green. docker compose up --wait waits for services to be running or healthy, and a failed wait would fail the deploy job instead.
Do it on your own machine
You need a terminal with curl, and Docker for the first step. No account is needed. You will build the bottom of the map on your laptop, then read the headers of this site to find each layer above it.
Run nginx in a container published on localhost only, and ask who answered. The Server header names nginx: this is one container, reachable only from your own machine.
docker run -d --rm --name map-lab -p 127.0.0.1:8080:80 nginx:alpine curl -sI http://127.0.0.1:8080/ | grep -i '^server' docker rm -f map-labAsk this site the same question. Expect server: cloudflare and cf-cache-status: DYNAMIC, because the HTML went through the edge to the server.
curl -sI https://www.matthewswong.com/en/ \ | grep -iE '^(server|cf-cache-status|cache-control|strict-transport-security|vary)'Ask for the same URL as Markdown. The content type changes and the response carries Vary: Accept: one URL, two bodies, which is why the edge must not cache this HTML.
curl -sI -H 'Accept: text/markdown' https://www.matthewswong.com/en/ \ | grep -iE '^(content-type|vary|cf-cache-status)'Find a hashed JavaScript file in the page and compare its headers. It should show public, max-age=31536000, immutable, usually with cf-cache-status: HIT.
curl -s https://www.matthewswong.com/en/ | grep -oE '/_next/static/[^"]+\.js' | head -1 curl -sI "https://www.matthewswong.com<paste-the-path-here>" | grep -iE '^(cf-cache-status|cache-control)'See where the time goes on one request: DNS, TCP connect, the TLS handshake and the first byte. Then draw the map from memory and label which stop produced each header you saw.
curl -o /dev/null -s -w 'dns %{time_namelookup}s connect %{time_connect}s tls %{time_appconnect}s first byte %{time_starttransfer}s\n' \ https://www.matthewswong.com/en/
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.The verify job fails on a commit. What reaches the server?Show the answer
Answer:Nothing. build-and-push needs verify and deploy needs build-and-push, and GitHub skips every job that needs a failed one. The site keeps running the last good image.
2.Why is the app published on 127.0.0.1 instead of on every interface?Show the answer
Answer:So only nginx, on the same machine, can reach it. Docker's Compose reference warns that without a host IP, Docker binds to all interfaces, which can expose the container directly to the internet.
3.You change a secret in GitHub's settings. When does the running site see it?Show the answer
Answer:At the next deploy. The deploy job writes .env from secrets each time and recreates the container; until then, the running container keeps the environment it started with.
4.A .js file returns cf-cache-status: HIT and the HTML returns DYNAMIC. Which machine produced each response?Show the answer
Answer:The .js file came from Cloudflare's edge cache and never reached the server. The HTML was forwarded to the VPS, where nginx passed it to the Next.js container.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.
- GitHub docs: Understanding GitHub Actions
- GitHub docs: Workflow syntax (on, needs)
- GitHub docs: Working with the Container registry
- Docker docs: Docker overview (images and containers)
- Docker docs: Compose services reference (ports, env_file)
- Docker docs: docker compose up
- Cloudflare docs: How Cloudflare works
- Cloudflare docs: Cache responses (cf-cache-status)
- curl manual (-I, -H and -w)