SSTAWSInfrastructureMonorepo

The Monorepo Playbook: SST v3, AWS, and TypeScript

By Ezequiel Carrizo
Ezquiel, the author Picture
Published on
Infrastructure
SST v3 (Ion)
Deployments
Dev, Staging, PR Previews, Prod
Services
API Gateway, Lambda, DynamoDB, Cognito, S3, CloudFront
Cost
Under $50/month at launch
Network connections and infrastructure

When I started building Interview Sparring, I knew I wanted a monorepo. But I did not want the complexity of Nx or Turborepo, and I did not want to manage infrastructure in the AWS console. SST v3 (Ion) gave me exactly what I needed: a TypeScript-native way to define my entire infrastructure and deploy it alongside my application code.

Hi there! Want to support me?

The Monorepo Structure

The monorepo has three application packages and one infrastructure package. No build tooling orchestration — just TypeScript path aliases and SST's built-in deployment pipeline.

interview-coach/
├── apps/
│ ├── web/ # Next.js 14 static export
│ ├── backend/ # TypeScript Lambda functions (DDD)
│ └── catalyst-ui-kit/ # Shared React component library
├── infra/ # SST infrastructure definitions
│ ├── api.ts # API Gateway + Lambda setup
│ ├── auth.ts # Cognito + Google OAuth
│ ├── storage.ts # 9 DynamoDB tables + S3 buckets
│ ├── web.ts # S3 + CloudFront static hosting
│ ├── email.ts # SES email configuration
│ ├── monitoring.ts # CloudWatch dashboards
│ └── routes/ # API route registrations
├── scripts/ # Seed data, migrations
├── package.json # Root workspace
└── sst.config.ts # Entry point — wires everything together

The catalyst-ui-kit is a custom React component library with 27 components. It lives in the same repo as the web app, imported via TypeScript path aliases. This means zero friction: change a component, see the result immediately, no npm publish step.

Infrastructure as Code with SST Ion

SST v3 introduced Ion, a new engine that compiles infrastructure definitions to Pulumi providers. The result is infrastructure-as-code that feels like writing application code: typed, composable, and testable. No YAML, no JSON, no HCL.

// sst.config.ts — the single entry point for all infrastructure
export default $config({
app(input) {
return {
name: "interview-sparring",
removal: input?.stage === "prod" ? "retain" : "remove",
home: "aws",
providers: { aws: { region: "us-east-1" } },
}
},
async run() {
const secrets = createSecrets()
const storage = createStorage()
const emailNotifs = isProd ? createEmailNotifications(storage) : null
const auth = createAuth(storage, secrets, emailNotifs?.configSet.name)
const api = createApi(storage, secrets, auth)
const web = createWeb(api)
if (isProd) {
createEmail()
createDashboards()
}
return { apiUrl: api.url, webUrl: web.url }
},
})

The run() function is the entry point. It creates resources in dependency order: secrets first, then storage, then auth (which depends on storage), then the API (which depends on everything), and finally the web frontend. Production-only resources like email and dashboards are gated behind if (isProd).

API Gateway: Declarative, Not Config-Heavy

The API is an API Gateway V2 instance with 10 route groups. Each route group registers a set of Lambda functions. The definition is concise and readable.

// infra/api.ts — declarative API definition
const api = new sst.aws.ApiGatewayV2("Api", {
domain: { name: "api.interviewsparring.com", dns: sst.aws.dns({ zone }) },
cors: {
allowOrigins: ["https://interviewsparring.com"],
allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
},
})
registerAuthRoutes(api, fnDefaults, sharedEnv, { storage, secrets, auth })
registerBillingRoutes(api, fnDefaults, sharedEnv, { storage, secrets })
registerSessionsRoutes(api, fnDefaults, sharedEnv, { storage })
// ... 10 route groups total

Every Lambda function shares the same runtime (Node.js 24.x), architecture (ARM64 for cost savings), and linked resources (DynamoDB tables, secrets). This consistency is enforced at the infrastructure level — a developer adding a new endpoint cannot accidentally use a different runtime or forget to link a table.

Hi there! Want to support me?

Multi-Environment Strategy

SST supports three environment tiers out of the box:

  • Dev: Local development with sst dev. API Gateway routes to your local machine, DynamoDB tables are ephemeral.
  • Staging: Deployed with sst deploy --stage staging. Full infrastructure on staging.interviewsparring.com.
  • Production: Deployed with sst deploy --stage prod. Retention-protected resources, custom domains, real Stripe keys.

Additionally, PR previews deploy temporary environments using the PR number as the stage name. This lets us test changes in a production-like environment before merging.

Cost Optimization

At launch, the entire infrastructure costs under $50/month. Here is how:

  • Lambda uses ARM64 (architecture: "arm64") — 20% cheaper than x86
  • DynamoDB uses on-demand billing for low-traffic tables
  • CloudFront caches aggressively (immutable assets get 1-year cache)
  • SES costs $0.10 per 1,000 emails — essentially free at our volume
  • Non-production environments are torn down after PRs merge

The Trade-Offs

SST is not without rough edges. The documentation is improving but still has gaps. Debugging Pulumi state issues can be painful. And the ecosystem is smaller than Terraform or CDK, so community examples are scarce.

But the trade-off is worth it. I can define my entire infrastructure — API, auth, storage, email, monitoring — in a single TypeScript codebase that my team can read and modify. When I add a new feature that needs a new DynamoDB table, I add three lines of TypeScript and run sst deploy. That is the promise of infrastructure-as-code, and SST delivers it better than anything else I have tried.