Skip to content
Ben is currently available for contract work for 3D & web solutions — reach out.

Back to Blog Listing

My Development Setup for a Modern SaaS Application

How I develop a seven-service TypeScript SaaS with local Node processes, isolated GCP infrastructure, fast Postgres tests, and shared contracts.

Ben Houston12 min read

Land of Assets is a modern SaaS application organized as a TypeScript monorepo. Its 19 workspace packages produce seven services: an API, a web app, a rendering site, and four workers for Chromium, Blender, FreeCAD, and glTF processing.

My development setup keeps those application processes on my laptop while connecting them to an isolated GCP project. Terraform provisions that project from the same modules used for preview and production. Tests use local Postgres when speed and isolation matter more than cloud fidelity. Drizzle, the shared SDK, and TanStack Router each own the schemas at their respective boundaries.

I chose this structure to maximize productive engineering time. On my M3 MacBook, the TanStack Start site loads about three seconds after I run pnpm dev. Most code changes appear in under a second, and roughly 2,000 tests finish in under 15 seconds. Routine development still exercises the managed services the application uses in production.

The setup follows a practical form of DRY: each fact has one owner. The database model, wire format, and UI model remain separate because they describe different boundaries. Shared definitions prevent those layers from disagreeing without forcing them into one universal schema.

Digitizing Fast Cheetah

The daily workflow: local code, personal cloud services#

I authenticate once on each machine. A normal development session starts with three commands:

pnpm db:personal &                    # opens a local Cloud SQL proxy on :5400
eval "$(pnpm -s env:personal)"        # exports DATABASE_URL, bucket names, etc.
pnpm dev

The authentication command uses gcloud auth application-default login to create Application Default Credentials. Google's client libraries discover those credentials without service-account keys or application-specific login code. The Cloud SQL Connector, Cloud Storage client, Pub/Sub client, and other SDKs use the same local identity to access my personal project.

I select the target environment through package scripts:

"gcloud": "gcloud auth application-default login && gcloud config set project ...",
"db:personal": "BACKEND=personal node scripts/src/sql-proxy.ts",
"env:personal": "BACKEND=personal node scripts/src/export-env.ts",
"env:preview":  "BACKEND=preview node scripts/src/export-env.ts",
"env:prod":     "BACKEND=prod node scripts/src/export-env.ts",

sql-proxy.ts pipes a local TCP socket through the Cloud SQL Connector. Before reporting success, it sends a query through the proxy. A bad login or tunnel fails during startup, before several dependent services have launched.

server.listen(5400, async () => {
  const url = new URL(databaseUrl);
  url.hostname = 'localhost';
  url.port = '5400';
  await getUserCount({ db: createDatabaseConnection(url.toString(), config.DB_MAX_CONNECTIONS).db });
  console.log('\x1b[32m%s\x1b[0m', `DATABASE CONNECTION VERIFIED: ${url.toString()}`);
});

export-env.ts loads the typed configuration for the selected backend and writes shell-escaped export statements to stdout:

const entries = Object.entries(config as Record<string, string | number>);
for (const [key, value] of entries) {
  if (typeof value === 'string' && value.length > 0) {
    process.stdout.write(`export ${key}=${shellEscape(value)}\n`);
  } else if (typeof value === 'number') {
    process.stdout.write(`export ${key}=${value}\n`);
  }
}

Running a different environment command changes the backend without requiring edits to an .env file. I use the personal project for routine work. The preview and production variants support controlled incident diagnosis, with read-only credentials and operations wherever possible. I restrict production access independently of these commands so that convenience does not weaken access controls.

A personal cloud environment built from production modules#

During application development, each developer uses a small GCP project with its own Cloud SQL instance, buckets, Firestore database, and Pub/Sub topics and subscriptions. These managed services replace a local stack of Postgres, a Pub/Sub emulator, and MinIO.

Terraform provisions that project with the modules used by preview and production:

# terraform/environments/personal/main.tf
locals {
  userName   = var.userName
  project_id = "drivecore-platform-${local.userName}"
}

module "project" {
  source = "../../modules/project"

  project_name       = "Land Of Assets - ${local.userName}"
  project_id         = local.project_id
  billing_account_id = var.billing_account_id
  org_id             = var.org_id
}

module "postgres" {
  source = "../../modules/cloudsql"
  ...
  instance_name     = "drivecore-postgres-${local.userName}"
  machine_type      = var.db_machine_type
  high_availability = false   # No need for HA in personal env
  ...
}

The personal environment uses a smaller database without high availability, keeps three days of backups, and uses pull subscriptions because local workers have no public Cloud Run URLs. Production adds the deployed services and production-only resources. Both environments call the same project, cloudsql, storage, firestore, and pubsub modules.

If I change a bucket policy or add a topic, I update the module once. I do not have to keep Terraform and a separate docker-compose.yml in sync, and my laptop avoids the CPU and memory cost of several local data services.

My personal project costs about US$8 per month, most of it from Cloud SQL. I consider that a good exchange for the engineering time it saves. The main trade-off is latency: code that makes heavy use of the database runs slower against Cloud SQL than it would against Postgres on the same machine. Development also requires a network connection.

The personal project also preserves isolation. A shared development database would reduce setup at the cost of data collisions and engineers blocking one another. For this project, individual GCP projects cost less than maintaining local imitations of four managed services. A team with an offline requirement, expensive cloud dependencies, or hundreds of engineers could reasonably choose another approach.

Tests use local Postgres#

The test suite needs a different balance. It uses a local Postgres instance to stay fast, deterministic, and independent of the network.

Vitest's global setup creates one empty database for the run, applies the current Drizzle migrations, and inserts the small amount of shared reference data:

const testDbName = generateTestDatabaseName('test_isolated');
await createDatabase(adminClient, testDbName, true);

const { db } = createDrizzleDatabase(createDatabaseUrl(url, testDbName));
await runMigrations(db);
await populateTestPlans(db);

Each test then runs inside its own transaction. beforeEach exposes the transaction-scoped Drizzle instance to the test and waits while the test executes:

transactionState.transactionPromise = this.transaction(async (tx) => {
  transactionDbInstance = tx;
  dbReadyFn();
  await rollbackPromise;
  throw new Error('Test transaction rollback');
}).catch(() => {
  // Expected: Drizzle rolls the transaction back.
});

afterEach resolves rollbackPromise and waits for the rollback to finish. The next test starts from the same migrated baseline without deleting rows, recreating the database, or rerunning migrations. Nested application transactions become savepoints, so production code can keep its transaction boundaries inside the test transaction.

Vitest creates the database once per test run and drops it during global teardown. On my M3 MacBook, this setup runs about 2,000 tests in under 15 seconds. I can keep the full suite in my normal edit-test loop instead of reserving it for CI.

The split serves two different needs: managed GCP services provide fidelity during interactive development, while local Postgres keeps tests fast and isolated.

Keep the feedback loop on the laptop#

"dev": "concurrently \"tsc -b -w\" \"pnpm -r --parallel dev\""

The stateful services run in GCP, while the application processes stay on my laptop as plain Node processes. This gives me direct debugger access and low memory use, with no container build in the edit-run loop.

The root command starts TypeScript build mode and each workspace's development process in parallel. Vite handles the web apps, while Node's watch mode restarts the API and workers. Most changes appear in under a second after the three-second startup.

Three implementation choices make that loop fast and informative.

TypeScript project references#

The root tsconfig.json describes the workspace as a composite project-reference graph:

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": { "composite": true },
  "references": [
    { "path": "./packages/sdk" }, { "path": "./packages/db" },
    { "path": "./packages/backend" }, { "path": "./packages/api" },
    { "path": "./packages/dashboard" }, ...
  ]
}

tsc -b -w follows that graph and rebuilds affected projects. The graph contains the 19 workspace packages plus the scripts project. TypeScript 7's native compiler prevents type-checking from dominating startup.

Bind Fastify ports before startup finishes#

Loading configuration and constructing a Fastify app still takes time. A raw net.Server binds each Fastify service's port first with pauseOnConnect enabled. It holds accepted sockets until Fastify is ready, then hands them to Fastify's server:

// packages/backend/src/server/earlyTcp.ts
export async function bindEarlyTcp(options: { port: number; host: string; backlog?: number }) {
  const pendingSockets = new Set<net.Socket>();
  const earlyServer = net.createServer({ pauseOnConnect: true }, (socket) => {
    pendingSockets.add(socket);
    socket.once('close', () => pendingSockets.delete(socket));
  });
  await new Promise<void>((resolve, reject) => {
    earlyServer.listen({ port: options.port, host: options.host }, resolve);
  });
  return {
    earlyServer,
    async handoff(targetServer: net.Server) {
      await new Promise<void>((resolve) => targetServer.listen(earlyServer, resolve));
      for (const socket of pendingSockets) {
        targetServer.emit('connection', socket);
        socket.resume();          // hand the connection to the ready app
      }
    },
  };
}

runFastifyServer binds the port, builds the app, waits for app.ready(), and performs the handoff. It also registers shutdown handlers for SIGINT and SIGTERM:

earlyTcpBinding = await bindEarlyTcp({ port, host });
const app = await options.buildApp();
await app.ready();
await earlyTcpBinding.handoff(app.server);
registerGracefulShutdown(app);

During the startup gap, clients hold their TCP connections until the application can process them instead of receiving an immediate connection-refused error. This small optimization removes retry logic and visual noise from parallel startup.

Typed routes on both sides of the API#

The frontend uses TanStack Start and TanStack Router, a choice I discuss in TanStack/Start vs Remix vs Next.js. TanStack Router infers route parameters, loaders, search parameters, and navigation calls across the app. The API client gets its types from the shared SDK. A route rename or API contract change therefore produces editor feedback during the same development loop.

Give each boundary one authority#

The Terraform modules establish one authority for infrastructure. I apply the same rule to the database and API while keeping their boundaries distinct.

Drizzle owns the storage schema#

Drizzle generates SQL migrations and TypeScript types from the database schema. Shared domain enums come from the SDK rather than a second list maintained for Postgres:

// packages/db/src/schema.ts
import { Enums as SdkEnums } from '@landofassets/sdk';

export const userTypeEnum = pgEnum('UserType', SdkEnums.UserType);
export const memberRoleEnum = pgEnum('MemberRole', SdkEnums.MemberRole);

UserType and MemberRole now have one list of valid values across the database, backend, and clients. The database schema still owns table layout, nullability, indexes, and relations. I do not ask a client-facing Zod schema to model storage concerns.

The deployed database can still drift if someone fails to apply a generated migration. Removing duplicate declarations does not remove the need for deployment discipline.

The SDK owns the wire format#

The shared SDK defines the API's Zod request and response schemas:

// packages/sdk/src/routes/users.ts
export const userSchema = z.object({ id: z.number().int(), name: z.string() });
export const listUsersQuerySchema = paginationOptionalSchema.merge(/* ... */);
export const listUsersResultSchema = listResultSchema(userSchema);

Fastify imports those schemas for runtime validation, handler types, and OpenAPI generation. fastify-file-router, another tool I maintain, maps the API directory tree to URL paths. Its defineRouteZod helper carries the Zod types into request and reply:

// packages/api/src/routes/users/get.ts
import { listUsersQuerySchema, listUsersResultSchema, userSchema } from '@landofassets/sdk';
import { defineRouteZod } from 'fastify-file-router';

export const route = defineRouteZod({
  schema: {
    querystring: listUsersQuerySchema,
    response: { 200: listUsersResultSchema, /* ... */ },
  },
  handler: async (request, reply) => { /* request.query is already typed */ },
});

The dashboard and CLI consume the same SDK. A renamed field or new required parameter produces compile errors at its call sites. The Zod declaration supplies server validation, OpenAPI documentation, and client types.

This approach introduces coupling. Publishing an SDK change may require coordinated updates across the API, dashboard, and CLI. In a monorepo, the compiler shows me that work in one change set, which is the behavior I want. Separate release cadences might justify generated clients or versioned contracts instead.

The limits of the setup#

Local Node processes still differ from Cloud Run containers. Personal infrastructure uses smaller capacity and pull-based job delivery. Preview and production remain necessary because no development environment reproduces deployment, load, IAM, and networking in full.

I want to remove differences caused by maintaining two architectures. Terraform defines each cloud building block. Drizzle defines storage. The SDK defines the wire format. TanStack Router defines frontend routes. Each definition has a boundary, and code on both sides consumes it.

This structure gives me a seven-service development environment whose TanStack Start site loads three seconds after I run pnpm dev, without a local copy of the managed cloud stack. Tests use local Postgres where speed and isolation matter more than cloud fidelity.

Authoritative boundaries prevent bugs caused by subsystems disagreeing about shared facts. Fast startup, compilation, reloads, and tests expose other bugs sooner. Together, these choices let me spend more time building the product and less time reconciling my tools.