A fourth host on the same jar: taking my kanban board off localhost
A board that could never leave localhost
For months my kanban board lived in playground/todoapp: React, Express, node:sqlite, bound to 127.0.0.1. It worked. It also had no authentication of any kind, and its GET /api/state handed the entire database to whoever asked. A perfectly reasonable design for something only my laptop can reach; a completely unshippable one for anything else.
I wanted it on a real URL, on my phone, from anywhere. So the interesting question was never "how do I add login" — it was where does this thing live.
A fourth host beat a fourth container
The obvious move is a new container with its own auth. I didn't, for two reasons.
First, zakaria.lu already owns everything the board needed: a LinkedIn OAuth flow and a single-owner concept (OwnerAccessService, matching the authenticated email against cvnext.owner-email). Second, the box is a 4 GB Hetzner instance and the app is capped at mem_limit: 1536m — and a second service is not just a container, it is a second image, CI job, CD workflow and volume. For roughly 500 lines of CRUD, that is a bad trade.
The jar was already serving three hosts: the gated dossier SPA on the apex, the public blog, and the CV builder. Each is a servlet filter that recognises its host and rewrites the path. FocusHostDispatchFilter is the third of those, and the board became the fourth host on the same jar, in the same container, behind the same Traefik router rule.
// Paths served as-is on the focus host; everything else forwards to the SPA entry.
private static final List<String> PASSTHROUGH_PREFIXES = List.of(
"/focus", "/assets", "/api", "/oauth2", "/login", "/logout", "/error", "/actuator");
Both of the first two entries are load-bearing in a way that is easy to discover the hard way. Drop /assets and every JS chunk request returns the SPA's HTML instead of JavaScript. Drop /api and the board's own calls get swallowed by its own fallback.
The filter runs after Spring Security — and that is only safe for one reason
The dispatch filter sits at Ordered.LOWEST_PRECEDENCE, so it runs after the security chain. Spring Security does not re-filter FORWARD dispatches, which means anything this filter forwards to has already escaped authorization.
That is acceptable here for exactly one reason: the only forward targets are static files — /focus/index.html and /focus/robots.txt — and neither carries a single row of board data. The path that does carry data is on the passthrough list, so it reaches the security chain normally. I wrote the constraint into the class javadoc as an imperative, because the next person to add a forward target will not rediscover it: never add a forward target that renders data.
Gated twice, with no dev bypass
Every endpoint under /api/focus/** is gated in two independent places:
SecurityConfig—.requestMatchers("/api/focus/**").authenticated(), so an anonymous caller gets 401. Without this line the whole API is public, because the terminal rule in the chain ispermitAll().FocusController—requireOwner(authentication, FORBIDDEN)as the first statement of every method, so a signed-in stranger gets 403.
There is deliberately no "open it up when OAuth is disabled" escape hatch, not even for local development. The consequence is real and I accepted it on purpose: working on the board locally requires actual LinkedIn credentials in backend/.env. Unlike the blog, there is no public read path to fall back to — this is one person's private data, and a dev backdoor is a production backdoor that happens to be spelled differently.
"First line of every method" is not actually first
A code review caught this one before it shipped, and it is my favourite thing I learned from the whole port.
A @RequestBody is deserialized during argument resolution — which happens before the controller method body runs. So on a malformed body, Jackson throws and requireOwner has never executed. My new exception handler dutifully echoed the cause message, which in Jackson's case names the DTO class and the field chain:
com.elzakaria.cvnext.focus.FocusTaskRequest["topicId"]
A signed-in non-owner could therefore trade a type-mismatched JSON field for my internal class and field names — a disclosure Boot's default error body deliberately withholds. FocusExceptionHandler now re-checks ownership itself and answers 403 before echoing anything.
The general rule I took away: anything that can answer a request before the controller body runs needs its own gate. Worth noting too that this repo's controller tests call the beans directly as plain Java methods, which bypasses argument resolution entirely — so that whole class of bug is invisible to them by construction.
Card order is a double, not an index
focus_task.position is double precision. Dropping a card between two others writes one row at the midpoint instead of renumbering the column:
static double positionBetween(Double prev, Double next) {
if (prev == null && next == null) return 1;
if (prev == null) return next - 1;
if (next == null) return prev + 1;
return (prev + next) / 2;
}
The client sends the two neighbours it dropped between and never a position, so an optimistic reorder cannot disagree with what gets stored. Repeated halving does exhaust double precision, so below a 1e-6 gap the column is renumbered 1..n and the neighbours re-read from the renumbered list.
Two details are worth more than they look. The renumber mutates managed entities in memory rather than issuing a @Modifying bulk update, which would bypass the persistence context and leave the same transaction holding stale positions. And neighbours resolve from the target column only: the Express original looked them up globally by id, so a neighbour living in another column returned that column's number and the card landed somewhere surprising. A real bug I ported the fix for rather than the behaviour.
Two traps that only fire at runtime
The board is skinned with @zakaria/brand-kit, my own design system, as a file: dependency. Its dist/ is both gitignored and dockerignored, so it exists on no CI runner and in no Docker build context — both the Dockerfile and the CI workflow now build the kit before the frontend's npm ci.
Worse, resolve.dedupe: ['react', 'react-dom'] in vite.config.ts is not defensive, it is required. npm links a file: dependency as a symlink, and the kit carries its own React. Without deduping, Vite resolves two React copies onto the page — and it fails at runtime, not build time, with the gloriously unhelpful Cannot read properties of null (reading 'useState').
The cookie that 500'd every sign-in
Signing in on the apex has to authenticate the subdomain, so in production the session cookie is widened from host-only to the whole zone via CVNEXT_SESSION_COOKIE_DOMAIN. I shipped it as .zakaria.lu — with the leading dot, the way cookie domains have looked for twenty years.
That dot is legacy RFC 2109 syntax, and Tomcat 11's RFC 6265 processor rejects it outright:
IllegalArgumentException: An invalid domain [.zakaria.lu] was specified for this cookie
So every session-creating request 500'd — sign-in included. The part that stung is why it got past me: a page-level smoke test cannot see it. Static pages still return 200, because they never create a session. When you touch a session cookie, verify with a request that creates a session, not with GET /.
The widened cookie is also a deliberate security trade: it now reaches the blog and the CV builder too. That is acceptable only because all four hosts are literally the same jar in the same container — the moment one of them becomes a separate deployment, this decision needs revisiting.
Where it ended up
focus.zakaria.lu. Anonymous visitors never see this — they get a lock screen, because /api/focus/** answered 401.Live, locked, and mine — running in a dedicated focus Postgres schema alongside the blog's, in the same database, in the container that was already up. The port added 25 backend tests (143 to 168) and brought Vitest into the frontend for the first time, mostly to pin the position algorithm on both sides of the wire: the four-case table above is mirrored in TypeScript as the optimistic guess painted during a drag, and if the two ever disagree, cards visibly jump on drop.
The best part is the part that isn't code: no new image, no new pipeline, no new box. One more filter, one more Vite entry, one more host on a jar that was already running.