Polski

How this site gets deployed

jchs.software runs on machines in my homelab. DevOps is my hobby, and my own site is a safe place to practise it.

This is the path a commit takes from git push to production.

The short version

  1. I push to main.
  2. A GitHub Actions runner on my own machine picks the job up.
  3. It installs the dependencies, runs the linter and the tests, builds the site and checks the JavaScript budgets.
  4. Podman builds a container image and pushes it to a private registry.
  5. The runner tells the app server which commit to run. The server does the rest.

Pull requests go through steps 2 to 4 as well, so every PR gets its own image. Only a push to main reaches step 5.

The build

The site is a Next.js app built with output: "standalone". The result is a server.js plus only the packages it really uses, so the image stays small.

The whole Containerfile:

FROM node:24-alpine
WORKDIR /app

COPY .next/standalone ./
COPY public ./public
COPY .next/static ./public/_next/static

ENV HOSTNAME=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]

The build runs on the runner. The image only copies its result. Both use the same major version of Node, so what I test is what runs.

Checks before the image

Four steps can stop a run before any image exists:

- name: Lint
  run: npm run lint

- name: Test
  run: npm test

- name: Build Next.js (standalone)
  run: npm run build

- name: Check bundle budgets
  run: npm run check:bundle

The last one is a small script. It adds up the gzip size of the JavaScript the page loads first and fails when the sum is over the budget. The 3D desk loads later and has a budget of its own.

Moving that check into CI taught me something. The same build, byte for byte, measured 206 bytes more on Node 24 than on Node 22, because the two ship different versions of zlib. On my machine I had 44 bytes to spare. On Node 24, the version CI runs, I was 162 bytes over.

I left the limit alone and cut code. The language switch used next/link, but going from / to /pl is a full page load anyway, so a plain <a> does the same job with 3 KB less JavaScript.

Image tags

Every image gets more than one tag:

Tag When What for
sha- + 7 characters always never moves, so I can go back to any build
pr- + number pull requests the latest build of that PR
main and latest a push to main what production runs
vX.Y.Z git tags releases

The deploy

The runner and the app live on different machines. The runner connects over SSH with a key that exists only for deploys. On the server that key is tied to one script, so it cannot open a shell.

CI sends one thing, the commit SHA:

- name: Deploy
  run: ssh -i "$HOME/.ssh/$SSH_KEY" -o BatchMode=yes "$REMOTE_USER@$REMOTE_HOST" "$GITHUB_SHA"

The script on the server takes it from there and checks that the version running afterwards is exactly that commit.

Deploys from main run one after another and are never cancelled half-way. PR builds work the other way round: a new push cancels the build before it.

What sits in front

Requests go through Cloudflare to a gateway in my network. Nginx on the gateway is the reverse proxy and passes them on to the virtual machine that runs the container.

That's all of it

About a hundred lines of YAML, a short Containerfile and one script on the server.

All posts