My personal webpage
  • Go 41.3%
  • JavaScript 25%
  • HTML 23.7%
  • CSS 10%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Olli-Agent 3e86042483
Some checks are pending
CI / verify-staging (push) Blocked by required conditions
CI / lint (push) Successful in 2s
CI / server (push) Has started running
CI / image (push) Waiting to run
Skip quiz questions whose text shows the answer inside a word
Leaks let every name under four letters through, so that Git would not be
found in "digit". PHP is three letters, and its summary and example both
name FrankenPHP, which the blank leaves whole: the question showed its own
answer. A short name that looks like an abbreviation is now found as
written inside other words. PHP is the only term this changes, and it now
appears in the quiz only as a wrong answer.
2026-10-16 11:37:02 +03:00
.forgejo/workflows Drop a stray quote that broke the release check 2026-10-15 14:11:07 +03:00
cmd/server Add a daily glossary quiz with a Go backend and high scores 2026-10-15 10:23:43 +03:00
deploy Add a daily glossary quiz with a Go backend and high scores 2026-10-15 10:23:43 +03:00
internal Skip quiz questions whose text shows the answer inside a word 2026-10-16 11:37:02 +03:00
migrations Add a daily glossary quiz with a Go backend and high scores 2026-10-15 10:23:43 +03:00
public Give the PHP, Node.js and Laravel examples a reason to pick them 2026-10-16 11:35:12 +03:00
query Add a daily glossary quiz with a Go backend and high scores 2026-10-15 10:23:43 +03:00
scripts Tell apart glossary terms that share an abbreviation 2026-10-15 02:02:43 +03:00
tools Add a daily glossary quiz with a Go backend and high scores 2026-10-15 10:23:43 +03:00
.gitignore Ignore Claude Code sandbox placeholders 2026-10-03 01:53:41 +03:00
.htmlvalidate.json Lint the page with html-validate 2026-10-03 01:53:08 +03:00
AGENTS.md Add a daily glossary quiz with a Go backend and high scores 2026-10-15 10:23:43 +03:00
compose.dev.yml Add a daily glossary quiz with a Go backend and high scores 2026-10-15 10:23:43 +03:00
go.mod Add a daily glossary quiz with a Go backend and high scores 2026-10-15 10:23:43 +03:00
go.sum Add a daily glossary quiz with a Go backend and high scores 2026-10-15 10:23:43 +03:00
package-lock.json Lint the page with html-validate 2026-10-03 01:53:08 +03:00
package.json Lint the page with html-validate 2026-10-03 01:53:08 +03:00
README.md Skip quiz questions whose text shows the answer inside a word 2026-10-16 11:37:02 +03:00
ROADMAP.md Add a daily glossary quiz with a Go backend and high scores 2026-10-15 10:23:43 +03:00
SEO.md Use one job title, Senior software engineer, on every page 2026-10-15 15:43:30 +03:00
sqlc.yaml Add a daily glossary quiz with a Go backend and high scores 2026-10-15 10:23:43 +03:00

olli.puhakka.dev

Olli Puhakka's personal site and CV, and a learning project for a production-grade CI/CD and platform-engineering workflow. See ROADMAP.md for the full plan and phases.

Current phase: 2–3, a Go backend with PostgreSQL. Four pages, the landing page public/index.html, the CV at public/cv/index.html, a glossary at public/glossary/ and a quiz at public/quiz/, share one stylesheet and one script (EN/FI, light/dark), served as written with no build step. The glossary adds a script of its own that renders its terms from graph.json. The quiz deals questions from the same file through a Go backend (cmd/server), which keeps players and high scores in PostgreSQL; see The backend. Two container images are built and tested by Forgejo Actions for each commit: the pages served by Caddy, which also proxies /api/ to the backend, and the backend itself. Two deployers pull them onto a Hetzner server: one runs whatever is on dev at the Authelia-gated staging host, the other whatever is on main at the public site. The server's host nginx terminates TLS and proxies to the containers on loopback.

Branch Site Access
dev https://olli-dev.puhakka.dev (staging) Authelia, admins only
main https://olli.puhakka.dev (production) public

Repository layout

.
├── public/
│   ├── index.html          # landing page: what I work on, recent work
│   ├── robots.txt          # search crawlers in, AI training crawlers out
│   ├── sitemap.xml         # the pages to index, listed in robots.txt
│   ├── cv/index.html       # the CV, served at /cv/
│   ├── glossary/
│   │   ├── index.html      # the glossary page, served at /glossary/
│   │   └── graph.json      # its terms and the edges between them
│   ├── quiz/index.html     # the quiz page, served at /quiz/
│   ├── css/site.css        # all styles, theme colors at the top
│   └── js/
│       ├── site.js         # theme and language toggles
│       ├── glossary.js     # renders and filters graph.json
│       └── quiz.js         # plays the quiz through /api/quiz/
├── cmd/server/main.go      # the backend's entrypoint and configuration
├── internal/
│   ├── database/           # the PostgreSQL pool, and migrations at startup
│   ├── glossary/           # reads graph.json
│   ├── quiz/               # questions, game rules, high scores, the HTTP API
│   │   └── quizdb/         # generated by sqlc from query/; do not edit
│   └── ratelimit/          # per-address request limits, in memory
├── migrations/             # the schema, numbered SQL files, embedded in the binary
├── query/quiz.sql          # the quiz's SQL, which sqlc turns into Go
├── sqlc.yaml               # sqlc's config
├── go.mod, go.sum          # the backend's modules, pinned with hashes
├── tools/go.mod, go.sum    # developer tools (sqlc), pinned apart from the backend's
├── scripts/
│   └── check-glossary.mjs  # CI's consistency check for graph.json
├── deploy/
│   └── Caddyfile           # the site container's web server config
├── .forgejo/workflows/
│   ├── ci.yml              # every branch but main: lint → smoke test → images → push → verify
│   └── release.yml         # main: find dev's images for the commit → verify production
├── .htmlvalidate.json      # lint rules for the HTML
├── package.json, package-lock.json   # html-validate, pinned with hashes
├── compose.dev.yml         # LOCAL dev: production's Caddy, the backend from source, PostgreSQL
├── ROADMAP.md
├── SEO.md                  # what search engines ask for, and the plan for this site
└── README.md

The production compose file is not here. It lives with the rest of the server's configuration in the kulpsin infrastructure repository (ansible/roles/site_deploy), because the server decides how the images run: read-only, no capabilities, loopback only, and where the database and its password are.

Local development

Serve public/ with the same Caddy image and Caddyfile production runs, over plain HTTP on port 8080, with the backend run from source and a PostgreSQL of its own:

docker compose -f compose.dev.yml up
# → http://localhost:8080
docker compose -f compose.dev.yml restart server   # after changing Go code

Just want to look at the HTML without Docker? It's still a plain file: python3 -m http.server 8000 --directory public. The quiz page then says it is unavailable, as it does whenever /api/ cannot be reached.

Lint exactly as CI does: npm ci --ignore-scripts && npx --no-install html-validate public/.

The backend needs Go 1.27. Its tests run against a real PostgreSQL, named by the usual PG* variables, each test in a schema of its own; without PGHOST the database tests are skipped:

go vet ./...
PGHOST=127.0.0.1 PGPORT=5432 PGUSER=quiz PGDATABASE=quiz go test ./...

After changing query/ or migrations/, regenerate the typed queries, which are committed, and CI fails when they have drifted: go tool -modfile=tools/go.mod sqlc generate.

Branches

Branch Role How it moves
main production: what is on main is live at olli.puhakka.dev pull request from dev, fast-forward only
dev default branch, live at olli-dev.puhakka.dev squash-merged pull requests from feature branches
feat/*, fix/* one change each pushed directly, cut from dev
git switch dev && git pull
git switch -c feat/contact-links
# …edit, commit…
git push -u origin feat/contact-links
# open a PR feat/contact-links → dev; CI runs on the branch; squash-merge
# when dev is ready to go live: open a PR dev → main; fast-forward merge

How the pipeline works

The images are built once, on dev, and promoted to main unchanged.

.forgejo/workflows/ci.yml runs on every push except to main:

  1. lint: html-validate over public/, installed from the lockfile, and scripts/check-glossary.mjs over the glossary's graph.json.
  2. image: stage public/, the Caddyfile and /version.json (naming the commit) as one reproducible layer. Serve them with the same Caddy version as the base image and check the pages, the glossary data and the revision. Then crane mutate appends the layer to the pinned caddy:2.11.4-alpine base and sets the org.opencontainers.image.revision label. On dev it pushes the result as git.puhakka.dev/olli/olli.puhakka.dev:<full sha>; on other branches it writes it to a file.
  3. server, beside the two above: gofmt, go vet, and sqlc diff against the committed queries; go test against a throwaway PostgreSQL the job starts in its workspace; then a static binary and graph.json staged as one reproducible layer. The staged binary is smoke-tested against a fresh database (it migrates, passes its health check and deals a round), and crane mutate appends the layer to the pinned distroless/static-debian13:nonroot base. On dev it pushes git.puhakka.dev/olli/olli.puhakka.dev-server:<full sha>.
  4. verify-staging (dev): poll staging's /version.json until it reports this commit. Green means live, not just pushed. Authelia lets /version.json through on the staging host, so this needs no login.

The runner needs Go, sqlc and PostgreSQL's server binaries (initdb, pg_ctl, psql) in its image, beside the tools listed in the kulpsin repository's runner/README.md. Its sqlc must be the release tools/go.mod pins, which the server job checks, so the two are bumped together.

.forgejo/workflows/release.yml runs on pushes to main:

  1. find-image: check that both images dev pushed for this commit exist and their revision labels name it, as the deployer will. Nothing is rebuilt.
  2. verify-production: poll the public site's /version.json until it reports this commit.

There are no third-party actions and no docker build. The runner (kulpsin, runner/) gives jobs no Docker and no privileges, so the image is assembled from its parts. The image first runs under production's container restrictions on staging, where the deployer health-checks it and rolls back a release that fails. main only ever gets commits that already ran there, and since nothing rebuilds them, production runs the very image staging ran. release.yml holds no secret: it reads the public image anonymously, as the deployer does.

Deploy: pull, not push

The pipeline never touches the server, and holds no credential for it. Instead, a deployer container per branch on the server:

  • asks the forge every minute which commit its branch points at,
  • pulls the images tagged with that commit, the site's and the backend's,
  • refuses them unless each image's own revision label names the same commit,
  • pins it by digest and runs docker compose up --wait,
  • rolls back to the previous digest if the new container does not turn healthy within two minutes. The healthcheck is in the server's compose file and probes Caddy's internal :8081/healthz.

So branch protection on main is the gate between a commit and production. Only dev pushes images, and main runs the one pushed for its commit.

The CI runner is on the developer's workstation, not the server, and runs jobs without Docker or privileges. Jobs queue while it is off, and both sites keep serving their last release. Details are in the kulpsin repository: runner/README.md and the Personal site section of ansible/README.md.

Rolling back

Revert on dev (staging updates) and fast-forward main. The revert is built and released like any other commit, and history stays the record of what was live. An emergency manual rollback is described in the kulpsin repository.

The backend

cmd/server is one Go binary. Today it serves the glossary quiz's API under /api/quiz/, which Caddy proxies to it; internal/quiz/http.go lists the endpoints. Its configuration is environment variables, listed at the top of cmd/server/main.go; the database is named by the usual PG* variables, with the password in a file (PGPASSWORD_FILE). It answers /healthz on a port that is never proxied, and server healthcheck asks it, for the container healthcheck, since the distroless image has no shell or wget.

The quiz

  • Questions come from graph.json (internal/quiz/questions.go): a term and its summary, a summary or example and its term, an abbreviation and what it stands for, and a cloud service and its counterpart on the other cloud. Only facts about one term, or a counterpart-of edge, make a question: a missing edge does not prove a wrong answer. The text names of the term asked about are blanked out, and a text that has a name inside another word (PHP in FrankenPHP) is not asked, since the blank would leave it showing. Wrong answers are picked from terms of the same kind and topic, and terms that share a name or distinguish each other never appear in one question. go test builds every question every term can give, so a glossary change that would make a question with two right answers, or a summary that names its own term, fails CI.
  • Today's round is ten questions, dealt from a random seed by the first player of the day (Europe/Helsinki) and the same for everyone after. Each player gets one; reloading carries on where it was. Options are shuffled per player. A practice round is new random questions and reaches no table.
  • Time: each question has 30 seconds from when the page asks for it. A late answer counts as wrong. The server sends one question at a time, without its answer, and checks every answer itself.
  • High scores: today's table ranks today's round; this week's adds up each player's daily rounds from Monday. Ties go to less time spent answering. There is no all-time table.

What it keeps

  • A player is a random token in an HttpOnly, SameSite=Strict cookie, made on the first daily round or nickname. The database keeps only the token's SHA-256, the nickname, and when the player was last seen.
  • A nickname is shown publicly; the page says so next to the field. Until a player chooses one they are anonymous_ and four digits. A nickname is 3–24 Latin letters, digits, _, . or -, and unique whatever its case; other scripts are refused, as their lookalike letters would let one player pass as another.
  • Rounds are deleted after two weeks, practice rounds after a day, and a player not seen for 60 days is deleted with everything of theirs. The cookie expires at the same time.
  • Addresses are counted in memory for the rate limits and never stored.

To take down a nickname, rename it in the database; the player keeps their scores under the new name:

UPDATE players SET nickname = 'anonymous_' || id WHERE lower(nickname) = lower('the-name');

Migrations

migrations/ holds numbered SQL files, embedded in the binary and applied in order at startup, each in a transaction, recorded in schema_migrations and serialised with an advisory lock. There are no down migrations: a failed release is rolled back to the previous image, which then runs against the newer schema, so a migration only adds. A column or table is dropped a release after nothing reads it.

Running it on the server

The server's compose file in the kulpsin repository runs the backend image as the service server beside the site's web, which reaches it at server:8080 (the Caddyfile's default), and a PostgreSQL of its own on an internal network that only server joins. nginx sets X-Real-IP, which reaches the backend through Caddy unchanged, for its rate limits. The deployer pulls both images for a commit and releases and rolls them back together, as it does for the form backend of the Arjessa tukena site. Until that is in place, the site runs without a backend and the quiz page says it is unavailable.

Forgejo setup

These settings live in Forgejo, not in this repository, so they are written down here.

Repository (Settings → Repository)

  • Visibility: public. The deployer reads the repository and the image anonymously, so making either private freezes the live site at its last release.
  • Merge styles: enable Squash and Fast-forward only, and disable the rest. Squash feature → dev; fast-forward dev → main, so main is always a commit that already passed CI on dev, and the two never diverge.
  • Enable Delete pull request branch after merge by default. dev is protected below, which stops the dev → main merge from deleting it.

Default branch (Settings → Branches): dev.

Actions secret (Settings → Actions → Secrets): REGISTRY_TOKEN, a personal access token (User settings → Applications) with write:package scope and nothing else. Every branch's workflow can read it, so it is deliberately unable to do anything except push images, and pushing an image does not deploy it.

Protection rule main (Settings → Branches → Add new rule)

  • Push: Disable push. Nobody pushes to main directly; merging a pull request through the UI still works.
  • Force push: disabled (implied by the above).
  • Merge whitelist: on, olli only.
  • Required approvals: 0. Forgejo does not count an author approving their own pull request, so with one account anything higher blocks every merge.
  • Status checks: on, requiring CI / lint (push), CI / image (push) and CI / server (push). These are the checks from the dev push; with fast-forward merges the commit being merged is the one that was tested. CI does not run again on main.
  • Block merge on rejected reviews: on.
  • Block merge if the pull request is outdated: on.

Protection rule dev

  • Push: enabled for anyone with write access.
  • Force push: disabled.
  • Status checks: optional, the same three checks as main.
  • Force push stays disabled because a deployer follows this branch: rewriting it would roll staging back to whatever commit it lands on.

Editing content

  • Text (public/index.html, public/cv/index.html): bilingual strings use data-en / data-fi attributes on .i18n elements; the text between the tags is the English fallback. Edit both. The pages link their stylesheet and script by absolute path (/css/site.css), so both resolve from /cv/.

  • Colors / type (public/css/site.css): CSS custom properties at the top (--accent, --ground, …) for light, dark, and explicit toggle.

  • Behaviour (public/js/site.js): the theme and language toggles, which remember the choice in localStorage. A language switch also fires an opd:lang event for text a page renders itself.

  • Glossary (public/glossary/graph.json): nodes are terms, edges relate two of them, and edgeTypes names each relation in both languages. A term has an id (kebab-case, unique, also its #anchor), a label, a kind, an expansion if the label is an abbreviation, optional aliases, distinguish and url, tags for its topics, and a summary and example in English and Finnish. tags at the top level names each topic in both languages; the page filters by them, and the check fails on a tag without a name or a name without terms. An edge is stored once, from one term to another, with an optional bilingual note; the page shows it from both ends, using the type's inverse label at the target unless the type is symmetric. Following a link to a term narrows the list to that term and the terms it links to. Keep nodes sorted by id, and run node scripts/check-glossary.mjs before committing.

    An edge states what is true of the two things everywhere, not how one installation happened to be set up. uses means every installation involves the target (Docker uses containerd); an optional integration or plugin is works-with (LiteLLM works with Redis). counterpart-of pairs services of two clouds, so both ends carry an aws or gcp tag, which the check enforces; self-hosted software and a managed service doing the same job are alternative-to.

    Abbreviations in the texts are explained where they appear. A term whose label or alias is written like one (OIDC, K8s, Cloud Run) becomes a link to its card, with its expansion as a tooltip. An abbreviation that doesn't earn a card of its own goes in abbreviations ("TTL": { "en": "Time To Live" }, with fi only where a Finnish gloss reads better) and gets a tooltip and a line in search results. A card is for a concept, standard or tool in the glossary's field with relations worth following; general vocabulary, units and organisations stay in abbreviations. The check fails on any word written in capitals that is neither, unless it is on its short list of names that only look like abbreviations (NVIDIA, Qwen3).

    Two terms that share an abbreviation follow Wikipedia's conventions. The primary topic, the meaning the texts use, keeps it as its label (MIG, Managed Instance Group). The other is titled by its full name and carries the abbreviation as an alias (Multi-Instance GPU, also MIG), so a search finds both. Each lists the other in distinguish, which shows a "Not to be confused with" line on both cards. The abbreviation in a text always links to the primary topic, so the other term's own texts and notes write its full name; the check enforces both sides. A second meaning goes in only when it belongs to the glossary's field and earns a card.