- Go 41.3%
- JavaScript 25%
- HTML 23.7%
- CSS 10%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| .forgejo/workflows | ||
| cmd/server | ||
| deploy | ||
| internal | ||
| migrations | ||
| public | ||
| query | ||
| scripts | ||
| tools | ||
| .gitignore | ||
| .htmlvalidate.json | ||
| AGENTS.md | ||
| compose.dev.yml | ||
| go.mod | ||
| go.sum | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| ROADMAP.md | ||
| SEO.md | ||
| sqlc.yaml | ||
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:
- lint:
html-validateoverpublic/, installed from the lockfile, andscripts/check-glossary.mjsover the glossary'sgraph.json. - 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. Thencrane mutateappends the layer to the pinnedcaddy:2.11.4-alpinebase and sets theorg.opencontainers.image.revisionlabel. Ondevit pushes the result asgit.puhakka.dev/olli/olli.puhakka.dev:<full sha>; on other branches it writes it to a file. - server, beside the two above:
gofmt,go vet, andsqlc diffagainst the committed queries;go testagainst a throwaway PostgreSQL the job starts in its workspace; then a static binary andgraph.jsonstaged as one reproducible layer. The staged binary is smoke-tested against a fresh database (it migrates, passes its health check and deals a round), andcrane mutateappends the layer to the pinneddistroless/static-debian13:nonrootbase. Ondevit pushesgit.puhakka.dev/olli/olli.puhakka.dev-server:<full sha>. - verify-staging (
dev): poll staging's/version.jsonuntil it reports this commit. Green means live, not just pushed. Authelia lets/version.jsonthrough 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:
- find-image: check that both images
devpushed for this commit exist and their revision labels name it, as the deployer will. Nothing is rebuilt. - verify-production: poll the public site's
/version.jsonuntil 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 acounterpart-ofedge, 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 ordistinguisheach other never appear in one question.go testbuilds 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=Strictcookie, 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-forwarddev→main, somainis always a commit that already passed CI ondev, and the two never diverge. - Enable Delete pull request branch after merge by default.
devis protected below, which stops thedev→mainmerge 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
maindirectly; merging a pull request through the UI still works. - Force push: disabled (implied by the above).
- Merge whitelist: on,
ollionly. - 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)andCI / server (push). These are the checks from thedevpush; with fast-forward merges the commit being merged is the one that was tested. CI does not run again onmain. - 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 usedata-en/data-fiattributes on.i18nelements; 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 inlocalStorage. A language switch also fires anopd:langevent for text a page renders itself. -
Glossary (
public/glossary/graph.json):nodesare terms,edgesrelate two of them, andedgeTypesnames each relation in both languages. A term has anid(kebab-case, unique, also its#anchor), alabel, akind, anexpansionif the label is an abbreviation, optionalaliases,distinguishandurl,tagsfor its topics, and asummaryandexamplein English and Finnish.tagsat 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,fromone termtoanother, with an optional bilingualnote; the page shows it from both ends, using the type'sinverselabel at the target unless the type issymmetric. Following a link to a term narrows the list to that term and the terms it links to. Keep nodes sorted byid, and runnode scripts/check-glossary.mjsbefore committing.An edge states what is true of the two things everywhere, not how one installation happened to be set up.
usesmeans every installation involves the target (Docker uses containerd); an optional integration or plugin isworks-with(LiteLLM works with Redis).counterpart-ofpairs services of two clouds, so both ends carry anawsorgcptag, which the check enforces; self-hosted software and a managed service doing the same job arealternative-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" }, withfionly 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 inabbreviations. 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.