Deployed at Home: my path toward Forward Deployed Engineer. Part 2 of 4, making the call: moving from .NET, Next.js and Keycloak to Go, templ and Zitadel, with two AI agents doing the building.
In Part 1 I measured my .NET back office on a laptop pretending to be a Hetzner server. The conclusion was clear: a lighter identity stack would save about half a gigabyte, worth €96 to €192 a year, and would not change which server I buy. On 2026-09-30 I even locked a rule into the project’s engineering constitution: “Do not rewrite the stack to lower machine requirements.”
Two days later I overrode it, and the rewrite was finished about three days after that. I didn’t write it by hand. Claude Code designed it and reviewed every commit, DeepSeek V4.1 Flash running in DeepSeek Harness wrote it, and guardrails written as code checked each step. My job was to make the calls that belonged to the owner.
This part covers both halves: why I made a call that goes against my own evidence, and what it took to let two agents carry it out safely.
What the agents did, and what I did
The rewrite took about three days of wall-clock time, from design approval on 2026-10-02 to the architect’s approval on 2026-10-05. The split of work looked like this:
| Step | Who | Output |
|---|---|---|
| Design | Claude Code with me, 2 to 3 rounds of questions | A design file with 28 numbered decisions, each with a reason |
| Research | a research agent (DeepSeek) | Four reports: Zitadel login and seeding, identity provider alternatives, Aspire with Go, the browser test stack |
| Plan | Claude Code’s plan-architect, max effort | 59 tasks in 12 waves, about 4,000 lines |
| Build | DeepSeek V4.1 Flash, through specialised sub-agents | About 19,300 lines of production Go and 61,000 lines of tests |
| Review | Claude Code | About 40 review findings and 133 decisions, answering DeepSeek’s questions |
| Attack | adversarial and security review agents | Bugs a normal review had missed, such as a cross-account foreign key |
| Gate | the guardrails | make verify, about six minutes, before every wave commit |
| Calls only I could make | me | The decision to rewrite, Zitadel, JWT cookies, Podman only, what to defer |
Across the run, the branch collected 332 commits, made by the DeepSeek session. Claude Code filed about 40 review findings, answered the questions DeepSeek raised, and gave a go-ahead before each wave. The guardrails refused work that couldn’t show a failing test turning green. Parts 3 and 4 explain the ideas behind the guardrails and how the channel between the two agents work. This part is about what they built, and why.
The reason I gave myself
The decision is written down in the design file Claude Code and I produced, and I’ll quote my own reasoning because it is easy to misremember later. The rewrite is for one language, one binary, and no JVM or Node runtime to operate. I left RAM out of the reason on purpose, because the research had already shown it doesn’t change the plan.
Running the old stack meant keeping five runtimes healthy on one box: a JVM for Keycloak, .NET for the API, the gateway and the migrator, Node for Next.js, and PostgreSQL. Each one had its own memory model, its own cold-start behaviour and its own way of failing. During the load test alone I had a .NET API killed at its limit, a YARP gateway crashing with SIGILL, Keycloak re-importing its realm on every boot, and Wolverine compiling C# inside the production container on the first request. Each of those is an operations problem, and I am the operations team. Whoever runs this after me will be one person on one box, so the design had to suit a small operator. That mattered more to me than shaving another few hundred megabytes.
The old project stays in the repository untouched. It is the behavioural spec and the fallback: the new code reads it for what the app must do and never translates it line by line. No code and no data move across.
What changed
| Concern | Before | After |
|---|---|---|
| Language | C# and TypeScript | Go |
| HTTP | ASP.NET Core minimal API, Wolverine | net/http with chi, plain service calls |
| UI | Next.js 16 on Node 22 | templ components, htmx, Alpine.js only in the calendar and pricing editor |
| CSS | Tailwind via Node | Tailwind 4 standalone CLI and daisyUI 5, no Node in the build |
| Data access | EF Core | pgx, sqlc for typed queries, goose migrations embedded in the binary |
| Identity | Keycloak (JVM) | Zitadel (Go), sharing the PostgreSQL instance in its own database |
| Edge | YARP gateway plus an external proxy | Caddy for TLS and routing |
| Image | .NET and Node images | static CGO_ENABLED=0 binary on distroless, built for linux/amd64 explicitly |
The starting memory ceilings in the new Compose file are postgres 768 MB, zitadel 512 MB, app 256 MB, caddy 128 MB and the one-shot migrate 128 MB, about 1.8 GB in total. The target is CPX22 (4 GB), and I want to know whether CPX12 (2 GB) can work.
I’ll be straight about where that stands. The new sizing run was planned as its own task and I deferred it. The k6 harness, the capture script and the Compose file for it are all written, and the memory limits are declared and enforced, but they have never been validated under load. Until that run happens, the 1.8 GB above is a budget I have yet to test.
Why Zitadel instead of Keycloak
Keycloak peaked at 759 MiB in the CPX22 run, level with PostgreSQL and the API, and it was the one JVM on the box. Zitadel is written in Go and can live in the same PostgreSQL instance as the app, in a separate database. The design marks Zitadel, the JWT cookie design and Aspire for local development as my choices. The agents checked what those choices would cost.
It came with one trap that would have quietly undone the point of the rewrite. Recent Zitadel versions default to Login v2, which is a separate Next.js container. Adopting it would have put Node back on the box. The researcher agent pinned ghcr.io/zitadel/zitadel:v4.19.4 by digest and found that Login v1 is still compiled into that release: with ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED=false, the browser goes to the built-in login and never to /ui/v2/. The Login v2 container would have idled at around 130 MiB against Zitadel’s 512 MB ceiling. That pin is now a standing risk in the project notes: re-measure the login surface before any Zitadel upgrade.
I also had the researcher check the alternatives before committing:
- tinyauth is small and is now OpenID Certified, but its access tokens are opaque random strings rather than JWTs, it has no logout or revocation endpoint, and local users have no groups, so there is nowhere to put an
ownerrole. - Authelia fits most of the design: RS256 JWT access tokens, refresh, revocation, PKCE and PostgreSQL storage. It has no RP-initiated logout and no admin API, so owners would live in a YAML file. It is the fallback if Zitadel turns out too large for CPX12.
- Pocket ID allows passkey login only, which ruled it out.
The auth design
I wanted JWTs so a future mobile client and JSON API can reuse the same validation, without ever exposing a token to browser JavaScript.
Some decisions I’d make again:
- One middleware handles both transports. It reads a
Bearerheader if there is one and the sealed cookie otherwise. Acurlwith an access token reaches a protected page through the same code path as the browser. - The
ownerrole comes from Zitadel’s project-roles claim on every request, so revoking it takes effect within one five-minute token lifetime. - Every query is scoped by account, and a resource from another account returns 404, never 403. A 403 tells an attacker the thing exists.
- When an htmx page fires several requests at once just as the token expires, only one refresh goes to Zitadel. The others wait for it and accept the cookie it wrote. Without that, rotating refresh tokens would log the owner out at random.
Moving the rules into the database
The rewrite was also a chance to stop trusting application code for the two rules that matter most.
Double booking is prevented by PostgreSQL. Every booked room gets an occupancy row with a btree_gist exclusion constraint on the room and the stay’s date range, limited to active statuses. A whole-property booking occupies every room in the house. Writes also take a per-house advisory lock, so that “this house has a whole-property booking” and “a room was added later” can’t race each other. The availability check in the UI is advisory only.
Ownership is enforced the same way. During review, the adversarial and security review agents found that a reservation in one owner’s house could reference a guest belonging to another owner, because the foreign key only pointed at the guest’s id. The fix was composite foreign keys: a reservation carries the account id, its house reference is (house_id, account_id) and its guest reference is (guest_id, account_id). A wrong pairing is now error 23503 from PostgreSQL, whatever the Go code does.
Money is int64 euro cents. Payment rows are append-only, and cancelling or voiding is a status change, never a delete.
What it cost, and what it looks like now
- The design was approved on 2026-10-02. The first Go commit landed that evening, and the plan was marked APPROVED early on 2026-10-05: 59 planned tasks, 52 bugs filed and fixed or parked, 332 commits.
- The app is roughly 19,300 lines of production Go and about 61,000 lines of tests.
- The full gate,
make verify, takes about six minutes: formatting, vet, build and unit tests, then the PostgreSQL integration suite through testcontainers, then browser flows in real Chromium against a real Zitadel, PostgreSQL and app stack. - A real
podman compose upof the production artefact passed its smoke test: only Caddy publishes ports, every service sits at its memory ceiling, and the access log carries no guest search terms.
Speed like that is only worth having if you can trust the result. The harness around DeepSeek is why I could accept a three-day rewrite at all, and some of what the agents got wrong is below. Every one of those was caught by a check, a review or a real start, and none by luck.
- The plan described Zitadel’s API responses from documentation. Four assertions in one task were plausible and wrong, and the test run showed it.
- A task to cut comment lines hit its number by joining lines together. Claude Code measured the comment text, not just the line count, and sent it back.
- A browser test was committed with its build tag still on, so it passed without running. The review found it.
What’s still open before the first paying guest: the sizing run I deferred, off-box backups and a CI pipeline.
The end result: pull request #4
The work ended as one pull request, merged into main from feature/go-rewrite: 332 commits and 514 files changed. The agents don’t merge. A pull request is blocked until adversarial, security and migration reviews have passed the current diff, and the merge itself was mine.

What the description says is in:
- A single Go binary (chi, templ, htmx, sqlc with pgx, goose) in the new
guesthouse.go/workspace. The oldguesthouse.aspirestays untouched as the behavioural reference, andguesthouse.landingwas not touched. - Owner-only access through Zitadel: OIDC code flow, bearer tokens and encrypted cookie sessions.
- Houses and rooms, reservations with a live preview, guests, payments, pricing (holiday calendars, rate overrides, quotes), the calendar and the dashboard.
- Bulgarian by default and English, with a phone-first layout.
- Local development through an Aspire AppHost, and production through a hand-written Compose file with Caddy, a distroless image and a healthcheck subcommand.
- A k6 load harness.
Plan tasks T1 to T55 and T59 are done. The remaining tasks were deferred or skipped, and the PR says so. Every wave passed its adversarial, security and migration reviews.
What the description says is not in:
- T56, the CPX22 and CPX12 sizing run, was deferred. The Compose memory and CPU caps are declared but not measured under load, and there is no sizing runbook.
- CI/CD to a container registry, off-box backups and uptime monitoring are out of scope for V1.
- The locale form still echoes the request URL into a hidden field. Only the Caddy log filter keeps it out of the logs, and I accepted that as a known risk.
make verify(fast tests, integration tests and the Chromium browser flows) was green in 361 seconds at one commit, but it hasn’t been re-run on the final head. The later commits are tests and docs.
I’d rather put the second list in the pull request than leave it for someone to find later. A merge description that names what’s missing is something I’d want from anyone handing me a system.
What this has to do with forward deployed work
Customers often ask for one thing and need another. Here the request was less RAM, and the research showed a rewrite wouldn’t change the bill. What the small operator needed was fewer runtimes to keep alive. I think an FDE’s job at that point is to make the call, say which evidence it goes against, and write the reason where the next person will find it. Mine is in the design file and in the superseded-decisions section of the engineering constitution, with a date.
The agents change the scale of that job. Three days of agent work produced more than I could have read line by line, so the call I made and the evidence I recorded mattered more, because they were the only part nobody could regenerate. Directing agents well is mostly the same skill as scoping work for a team: lock the decisions that matter, write each reason down, and leave the rest to something that can be checked.
The other half is being honest at handover. The list above, sizing unmeasured, no off-box backups, no pipeline, is what I’d put in front of a client before they took the system over, and it’s also the order in which I’d work through it.
Next: Part 3, guardrails written as code, and why every check has to be able to fail.
