I wrote several ticketing apps at CalPERS, back when the stack was PHP. This is that same kind of app again, rebuilt with the stack I actually use now at Playful Programming: Fastify inside an NX monorepo, Drizzle, and Postgres. Instead of a toy scaffold I set out to build something structurally close to the real thing, a support ticketing API with real authentication, real tests, and a folder structure pulled directly from Playful’s own public repo rather than whatever a generator handed me.
No frontend on this one, on purpose. Postman does the testing here. The UI shows up in the next app in this series.
GitHub: fastify_ticket_system
Tools & Tech Stack
- Fastify 5
- NX monorepo
- TypeScript
- Drizzle ORM (pinned to a specific beta version to match production)
- PostgreSQL 16, via Docker
- Redis, via Docker (provisioned now, used in a later app)
- JWT auth with bcrypt password hashing
- Vitest
What’s Actually Here
A three-table schema: users, tickets, and ticket_comments. Agents register and log in with real credentials, not a session cookie standing in for a login system. Every ticket route sits behind a requireAuth check. Comments are tied to whichever agent is actually logged in, read off the JWT itself, not something the client gets to claim in a request body.
Authentication, Built the Fastify Way
Fastify doesn’t do middleware the way Express or Hono does. Instead of chaining functions onto individual routes, you decorate the Fastify instance itself with new capabilities, and any route can opt in with one line. requireAuth is a decorator that calls request.jwtVerify() under the hood, and any route that wants protection just adds { onRequest: [fastify.requireAuth] } to its options.
It’s a genuinely different mental model from what I was used to, and it took a couple of wrong turns with TypeScript’s module augmentation to get the types to cooperate (more on that below). Once it clicked, it turned out to be a nice pattern. A route file can’t accidentally forget to protect itself the way it might with manually-chained middleware, because the check lives right there in the route definition.
Matching a Real Company’s Folder Structure
I didn’t want to build this against NX’s default scaffold and call it done. I pulled the actual structure from Playful’s public repo and matched it: routes grouped by domain (routes/tickets/, routes/users/), not split into separate routes/services/repositories layers. Each route file sits next to its own schema file and its own test file. Database logic lives in packages/db, a separate NX library that both the API and a future background worker can import from, rather than either app owning its own private copy of the Drizzle client.
That last part matters more than it sounds like. Once you have two apps in the same monorepo that both need database access, you either share a real package between them, or one starts reaching into the other’s internals with a relative import. The second option breaks NX’s dependency graph and quietly welds two supposedly-independent projects together. I didn’t hit that problem myself in F1 since there’s only one app right now, but I built the seam in ahead of the worker app that’s coming in F3.
Challenges
A one-word typo cost about twenty minutes and five different wrong diagnoses. My docker-compose file had POSTGRES_PASSWORD: POSTGRES_USER instead of an actual password value. Every symptom that came out of it looked like something else: drizzle-kit’s CLI just hung silently instead of failing, a raw connection test eventually surfaced a real 28P01 password authentication failed, and along the way I chased port collisions and IPv6 resolution issues that had nothing to do with the actual problem. The fix that would have caught it immediately was sitting the whole time in docker logs, which shows every failed authentication attempt Postgres itself sees. I didn’t check it until after I’d already ruled out three other things.
The pinned Drizzle beta doesn’t match its own documentation in places. The client constructor syntax changed between the stable release and the beta (drizzle(pool, { schema }) became drizzle({ client: pool, schema })), and the relational query API changed even more significantly, requiring a defineRelations() call that doesn’t type-check cleanly for self-referencing tables. I built the relations file, hit a type error that Drizzle’s own example code doesn’t seem to avoid either, and made the call to fall back to the plain query builder instead of fighting an unstable beta API. The relations file still exists in the codebase since it documents the schema honestly, it’s just not wired into the client.
Fastify’s autoload plugin doesn’t see through a bundler. This was the strangest one. Tests colocated next to route files (login.test.ts sitting beside login.ts) were being picked up by @fastify/autoload and loaded as if they were real Fastify plugins, crashing the dev server because Vitest can’t run inside a CommonJS require chain. I added an ignoreFilter option to skip test files, and it still didn’t work, because autoload was scanning NX’s compiled output, where every file is .js, and my filter was checking for .ts. Once I actually logged what autoload was checking against, instead of guessing, the fix was one line.
Lessons Learned
Silent failures need to be made visible, not stared at harder. Every hard bug this app produced (the auth failure, the autoload mismatch) looked like nothing was happening, rather than throwing a clear error. In both cases the fix was the same: stop guessing at the code and go check what the tool itself was actually doing, container logs in one case, a debug print statement in the other.
A pinned beta dependency is a real trade-off, not just a version number. Matching Playful’s exact drizzle-kit version meant hitting API drift that the stable release wouldn’t have had. That’s a legitimate cost of dependency pinning for production parity, and knowing when to stop fighting an unstable API and fall back to a more basic approach is its own skill.
Monorepo tooling has real setup cost that a single-project scaffold doesn’t. This app took meaningfully longer to get off the ground than a plain pnpm create fastify project would have, and almost none of that cost came from Fastify itself. NX’s workspace setup, its inconsistent generator support across its own plugins (one generator offered Vitest out of the box, another didn’t), and reconciling its default folder conventions against a real company’s actual structure all added real time. Worth knowing going in, rather than assuming a new framework is slow when the monorepo is what’s actually slow.
Test Coverage
23 tests across 6 files, covering registration, login, protected-route access, and the full ticket and comment CRUD surface. Every route has both a success-path test and at least one failure-path test (missing auth, invalid input, or a nonexistent resource), run against a real Postgres instance rather than a mock.