Why I built the pipeline

I'm applying for junior cloud/DevOps roles, and the actual job is less about any single app and more about how code gets from a commit to a running server safely. Rather than just reading about Docker and CI/CD, I used this site itself as the sandbox — low stakes, but a real pipeline with real consequences if I got it wrong.

What I built

The app is packaged with a Dockerfile based on python:3.13-slim, running under Gunicorn with two workers. requirements.txt is copied and installed before the rest of the source code, so Docker's layer cache skips the dependency install entirely when only application code changes.

Deployment is a single-service docker-compose.yml that pulls the built image, binds it to a fixed address on the VPS, loads secrets from an env file, and restarts automatically if the container ever crashes.

On top of that sits a three-stage GitHub Actions pipeline that runs on every push and pull request:

1. lint + test 2. build & push image 3. deploy

The pipeline, stage by stage

Lint and test: every push and PR runs ruff for linting and pytest for the test suite. Nothing moves forward until this passes.

Build and push: the image is built with Docker Buildx and pushed to GitHub Container Registry, tagged both :latest and with the commit SHA. Pull requests only verify that the Dockerfile still builds — the push to GHCR only happens on a real push to master, so a PR can never publish an image.

Deploy: on a successful push to master, GitHub Actions SSHes into the VPS to trigger the deploy.

What I learned

The most useful lesson wasn't Docker syntax, it was thinking about blast radius. The deploy step sends a command over SSH, but the server doesn't actually run whatever text is sent, the deploy key's entry in authorized_keys has a forced command= restriction, so that key can only ever run one fixed deploy script, no matter what the workflow sends. If the key ever leaked, it still couldn't be used to run arbitrary commands on the server.

I also learned to separate "verify" from "publish": every PR proves the code lints, passes tests, and still builds into a working image, but only a merge to master is trusted enough to actually push that image and deploy it.

Stack

Python Flask Docker Docker Compose GitHub Actions GHCR ruff pytest Gunicorn SSH