ZB Field Notes

One Spring Boot jar, two hosts: a gated SPA and a public blog

The problem: a great site Google can't read

My CV site is a single-page app gated behind LinkedIn sign-in. That's deliberate — I want to know which recruiters are looking. But it also means search engines see an empty shell: nothing to index, no organic discovery. A blog fixes that, as long as the blog itself is public and server-rendered. The real question was how to add one without standing up a second app, a second container, and a second deploy pipeline.

One jar, two hosts

The answer: serve the blog from the same Spring Boot jar that already serves the SPA, but on a second hostname — blog.zakaria.lu alongside the apex zakaria.lu. Traefik points both names at the same container with one combined rule:

- traefik.http.routers.cvnext.rule=Host(`zakaria.lu`) || Host(`blog.zakaria.lu`)

Inside the app, a single servlet filter inspects the host and, when it's the blog host, forwards the request under a /blog prefix. The Thymeleaf controllers live at /blog/**; the SPA keeps the root. No collision on /.

if (isBlogHost(request, blog.getHost())) {
    String target = "/".equals(path) ? "/blog" : "/blog" + path;
    request.getRequestDispatcher(target).forward(request, response);
    return;
}
chain.doFilter(request, response);

A forward, not a redirect — so the visitor keeps the clean URL blog.zakaria.lu/my-post while it's handled by /blog/my-post internally. Canonical and Open Graph URLs come from config, not the request path, so the proxy hop never leaks into them.

The subtlety that makes it safe

Spring Security, by default, does not run its filter chain on FORWARD dispatches. That sounds alarming until you notice what actually lives under /blog: only public, read-only pages. Every write — creating or editing a post — is a JSON call to /api/blog/admin/** on the apex, which the forward never produces and which is gated by an owner check. So a forwarded request can't reach anything privileged.

A schema, not a database

Posts live in their own Postgres schema (blog) inside the existing database — clean separation, no second datasource. One gotcha worth writing down: Hibernate's ddl-auto=update happily creates tables but won't reliably issue CREATE SCHEMA. So a tiny initializer runs first:

CREATE SCHEMA IF NOT EXISTS blog;

The trade-off

Coupling the blog to the main app means a blog bug could, in principle, affect the dossier — so the image path is deliberately non-fatal: if storage isn't writable it returns a 503 instead of crashing the process. In exchange I get one image, one pipeline, one place to reason about. For a one-owner site, that's a trade I'll take every time.