Dockier
Engineering

From Encore to Fastify: rebuilding our scanner on plain TypeScript

Andrea — Co-founder · 11 May 2026 · 9 min read

Back to blog

When we set out to replace our FastAPI monolith, we prototyped on Encore.ts first. It was the right call to try — typed contracts end-to-end, no OpenAPI bridge, zero ceremony. It got us moving fast and proved the shape of the API we wanted. Then we ran into the limits that always show up around month two: where the framework wants to run, how it wants to deploy, what it wants to own. We rebuilt on Fastify + Zod + Supabase over six weeks and we are not going back.

This is the migration in honest terms — why we left Encore, why we picked Fastify, and the 47 minutes we broke production the night of the cutover.

In the Dockier stack · Frontend — Vite + React 19 + Tailwind v4 on Cloudflare Pages · Backend — Fastify 5 + TypeScript service modules selected by SERVICE_NAME · Auth — Supabase passwordless + JWT (signed with shared JWT_SECRET) · Database — Supabase Postgres (single source for app data and migrations) · Background jobs — pg-boss on the same Postgres

The architecture today

FRONTEND Vite · React 19 · Tailwind v4 Cloudflare Pages Authorization: Bearer <Supabase JWT> GATEWAY Fastify gateway · CORS · JWT verify · OpenAPI FASTIFY SERVICE MODULES · SERVICE_NAME selector auth users projects roles code-analysis git-integration image-builder deploy integrations notifications Supabase Postgres RLS · pg-boss · migrations Supabase Auth passwordless · 2FA · OAuth
Every service runs the same Fastify binary; `SERVICE_NAME` decides which routes are mounted. One Postgres, one auth provider, one gateway.

Why we left Encore

Encore wasn't broken. It was constrained in three ways that started costing us:

  • Where it wanted to run. Encore's deployment story assumes its own platform (or a heavy self-host). Our frontend already lives on Cloudflare Pages. We wanted the backend either on Workers or on a single boring VM — both are awkward with Encore.
  • What it wanted to own. Migrations, queues, secrets, and infra were all things Encore had opinions about. We already had Supabase Postgres as the system of record and didn't want to fork that responsibility.
  • Vendor pull. A typed-client generated from server code is great until you wonder what happens if the framework changes its mind. The portability tax was higher than it looked.

The typed-contract value we originally went to Encore for — the thing that actually mattered — turned out to be reproducible with much less framework.

Why Fastify (+ Zod + OpenAPI)

We picked Fastify 5 with fastify-type-provider-zod. The combination gives us the same client-server typed-contract value as Encore via a different path:

  • Zod schemas as the source of truth. Routes declare their input/output schemas in Zod; Fastify validates them at runtime; OpenAPI docs are emitted automatically; the frontend imports the same Zod schemas for client-side validation and inferred TypeScript types.
  • Single binary, many services. All services compile to the same Fastify binary. We select which service to run with SERVICE_NAME=auth (or users, projects, …). Local dev runs the gateway with every service mounted in-process; in production each service is its own deploy unit.
  • No magic to debug. When something breaks at 02:00 UTC, you read Fastify source. That's it. There's no platform-layer mystery.
// backend/src/services/code-analysis/routes.ts
import { z } from "zod";
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod";

const ScanRequest = z.object({
  repo: z.string(),
  ref: z.string(),
  scanners: z.array(z.enum(["semgrep", "sonarqube", "custom"])),
});

const ScanResult = z.object({
  findings: z.array(z.object({
    pkg: z.string(),
    severity: z.enum(["critical", "high", "medium", "low"]),
    rule: z.string(),
  })),
});

export const scanRoutes: FastifyPluginAsyncZod = async (fastify) => {
  fastify.post("/scans", {
    schema: {
      tags: ["code-analysis"],
      body: ScanRequest,
      response: { 200: ScanResult },
    },
    handler: async (req) => {
      // req.body is typed as ScanRequest; the return must match ScanResult.
      return runScan(req.body);
    },
  });
};

The frontend imports ScanRequest and ScanResult directly from the backend package (a pnpm workspace dependency), so the contract is exactly the same on both sides. No codegen step.

The migration plan

We didn't rewrite. We strangled. The Encore router stayed up; a Fastify gateway took over slices of traffic as each service migrated.

Six-week migration · Encore → Fastify · 2026-04-01 → 2026-05-12 Week 1 Week 2 Week 3 Week 4 Week 5 Week 6 auth · users · roles projects · git-integration code-analysis (scanner) 12-day shadow compare integrations · notifications image-builder · deploy Cutover + decommission Encore cutover 22:00 UTC · cutover
The code-analysis swimlane was the long one because we shadow-compared against the Encore scanner for 12 days before flipping traffic.

The night of the cutover

We flipped the gateway at 22:00 UTC. By 22:14 three things were on fire:

  • Background jobs lost their context. Encore's queue abstraction had been managing connection lifecycle for us. pg-boss (which we moved to because it sits on the same Supabase Postgres) handles things differently — and a few jobs depended on a session variable that was no longer pinned. We fixed it by passing the GUC explicitly into the job payload.
  • JWT clock skew. The Fastify gateway validates Supabase-issued tokens with the shared JWT_SECRET. The default jsonwebtoken library is strict about iat/exp. Two regions had clocks drifted ~7 seconds. We added clockTolerance: 30 and filed a follow-up to fix NTP.
  • Stale env in CDN. The Vite build was reading the API base URL from an env injected at build time. We'd updated the source but not re-built. Five-minute fix, embarrassing.

47 minutes later we were green. No data loss. No customer-visible findings lost. The 12-day shadow comparison saved us — we knew the two implementations agreed before we sent traffic.

What we'd do exactly the same

  • Shadow both implementations before cutover. Diff their outputs row by row on real workloads. The Encore and Fastify scanners disagreed on 0.4% of findings; every disagreement was a quirk in the Encore version that we'd been shipping.
  • Strangle, don't rewrite. Two-week rewrites become four-month rewrites. Slicing meant we shipped value continuously instead of with a big bang.
  • Pin the contract to the schema layer. Zod schemas plus a shared pnpm workspace package gave us the same end-to-end typed-contract benefit Encore had — without depending on the framework. If we ever leave Fastify, the schemas come with us.

What's next

With Fastify underneath, the next analyser is a one-day project. We're shipping container-image scanning in June and the SBOM diff view in July. Both arrive via the same pattern: a new services/ folder, a route module, a Zod schema. The early-access form on the home page is the way in if you want to be in the cohort that gets those first.

Where this leaves the marketing copy: the Developer experience section on the home page still references FastAPI from an even earlier iteration. We are updating it. The headline is short — Dockier's API is Fastify + TypeScript today, will be Fastify + TypeScript tomorrow, and the only thing we promise is the OpenAPI contract.