Skip to content
Back

Shipping a full-stack app on AWS works better when the AWS Console is not the source of truth. Frontend, secrets, domains, and stage rules belong in TypeScript next to the application, reviewed in pull requests, and applied the same way in every environment.

SST defines the app in code — usually from a single sst.config.ts — and automates the underlying AWS resources. This site is a Bun + Turborepo monorepo with Next.js on OpenNext, and stages for local, shared preview, and production.

Three infra stories stay separate:

  1. Deployed today — sst.config.ts imports the Next.js / OpenNext stack only.
  2. Defined in-repo, not yet imported — container API infra (Vpc → Cluster → Service + Router) lives under packages/infra and stays commented out of run() until that stack should deploy.
  3. Related pattern elsewhere — Lambda + API Gateway for a Hono API is in Backend APIs with Hono, Drizzle, Zod OpenAPI, and SST. That note is not what this site’s sst.config.ts runs.


1. Why SST for This Stack

SST lets developers define app features as Components without assembling every low-level resource by hand.

  • Higher-level components used here: sst.aws.Nextjs, sst.Secret, and (when the API module is wired) sst.aws.Service, sst.aws.Router, sst.aws.Vpc, and sst.aws.Cluster.
  • Resource linking so runtime code can read infrastructure through a typed Resource SDK instead of hardcoded names and ARNs.
  • sst dev as a unified local environment: infra watcher, frontend (and later container) dev processes from the same CLI, plus Live when Lambda handlers are in the stage.

This site: Next.js via OpenNext, stage-based domains, edge Basic Auth on non-prod, application packages separate from packages/infra. SST’s comparison to Pulumi or CDK for Terraform is in the SST FAQ.



2. How SST Compares to Other IaC

All of these tools share infrastructure as code. SST is framed for developers; CDKTF and Pulumi are primarily for DevOps engineers. SST still uses open-source Pulumi and bridged Terraform providers; it does not require a Pulumi account, and app state stays with the team’s cloud account (SST docs).

ToolMental modelStrengthWhy SST fits app stacks here
Terraform / OpenTofuDeclarative HCL, provider-centricUniversal, mature state and workflow; strong for org-wide platform teamsApp and infra stay split; no first-class Live, linking, or Nextjs DX for this monorepo
AWS CDKTypeScript constructs → CloudFormationDeep AWS coverage and Constructs ecosystemStrong AWS-native modeling; weaker unified app loop (linking, sst dev, frontend + backend multiplexer) for this workflow
Pulumi / CDKTFGeneral-purpose IaC in real languagesFlexible providers; good when a platform team owns the programSST sits on Pulumi but adds app components, linking, and sst dev without a full Pulumi program for every feature
Serverless FrameworkFramework focused on serverless appsFamiliar Lambda packagingNarrower than full-stack Next/OpenNext, containers, and stage workflows used here
Vercel / PaaSGit push → hosted platformFast frontend DX with little AWS surfaceStrong when only a managed frontend is needed; SST when TypeScript ownership of AWS resources, domains, secrets, and optional ECS/API matters

  • SST components encode common app patterns and still allow transform plus 150+ providers when lower-level control is required.
  • Linking closes the runtime gap that Terraform or CDK usually fill with hand-wired env vars and IAM. sst dev is a product feature rather than an assemble-yourself local stack.
  • SST still has state — local plus a backup bucket — so managed resources should not be edited by hand in the Console (Basics). Backup of that state, and when a snapshot is not a failover, is Disaster Recovery in System Design.

SST is a poor fit when a platform team already owns shared networking in Terraform or OpenTofu, when Pulumi or Terraform is the team language for multi-cloud control planes, or when CloudFormation-native CDK construct libraries are the hiring and review surface.



3. Project Shape: sst.config.ts and packages/infra

SST supports drop-in mode — a single sst.config.ts next to a Next.js app — and a monorepo where the config stays at the root and infrastructure is split into modules. This project uses the monorepo shape.

text
sst.config.ts          app name, region, protect / removal, imports
packages/infra/        Next.js, secrets, domain, edge, optional API/VPC
apps/web               Next.js site
apps/api               Hono / Mastra API
packages/*             shared auth, db, content, design system

The config file defines app identity and safety rules. The run function loads infra modules. Today only Next.js is imported; the API module stays commented until that stack should deploy:


sst.config.ts
export default $config({
  app(input) {
    return {
      name: "my-app",
      removal: input?.stage === "production" ? "retain" : "remove",
      protect: ["production"].includes(input?.stage),
      home: "aws",
      providers: {
        aws: {
          region: "us-east-1",
        },
      },
    }
  },
  async run() {
    // await import("./infra/api")
    await import("./infra/nextjs")
  },
})

  • nextjs — OpenNext deployment and domains (imported today)
  • secrets — stage secrets such as edge Basic Auth credentials, plus database and auth secrets for the API path
  • domain / edge — apex domain and non-prod Basic Auth at the edge
  • api / router / vpc / cluster — container API path, defined but not imported yet

Business logic stays out of these files. Infra modules create and link resources; application packages consume them.



4. Components in This Stack

The site deploys with sst.aws.Nextjs. Production uses the apex domain; other stages get {stage}.example.com.


packages/infra/nextjs.ts
const isProd = $app.stage === "production"
const domain = "example.com"

export const nextjs = new sst.aws.Nextjs("Web", {
  path: "apps/web",
  domain: {
    name: isProd ? domain : `${$app.stage}.${domain}`,
    redirects: isProd ? [`www.${domain}`] : [],
  },
  warm: isProd ? 1 : 0,
  environment: {
    NEXT_PUBLIC_SITE_URL: `https://${domain}`,
    SST_STAGE: $app.stage,
  },
  openNextVersion: "4.0.3",
  edge,
  dev: {
    command: "bun run dev",
    directory: "apps/web",
    url: "http://localhost:3000",
  },
})

  • Production keeps one warm server instance; non-prod uses warm: 0. The edge prop attaches non-prod Basic Auth. The dev block tells sst dev how to start the local Next process.
  • Secrets are first-class resources, not loose .env files: sst.Secret("Username"), Password, DatabaseUrl, BetterAuthSecret, AiGatewayApiKey.
  • When the API module is imported, the path is Vpc → Cluster → Service, with Router for api.example.com or {stage}.api.example.com. Until await import("./infra/api") is enabled, that stack does not deploy.
  • A different shape — Lambda behind API Gateway with a Lambda authorizer — is the pattern in Backend APIs with Hono, Drizzle, Zod OpenAPI, and SST. That uses sst.aws.Function and sst.aws.ApiGatewayV2. It is not the infrastructure this site’s config currently loads.


5. Linking Instead of Hardcoded Env

Resource linking is the bridge between infrastructure and runtime. A resource is created, passed in link, and read with the SDK.

  • Linking injects values, generates types (sst-env.d.ts), and grants permissions where the component supports them.
  • On this site today, the Next.js stack pulls edge Basic Auth credentials through the edge configuration. The web app itself does not read Resource.* for the database.
  • When the API module is imported, secrets are linked into the container service.

ts
import { Resource } from "sst"

const client = postgres(Resource.DatabaseUrl.value, {
  ssl: "require",
})

  • Linked values are available on the server side only. Client components only see what is passed in explicitly.
  • For CLIs that need stage secrets — Drizzle migrations, Better Auth schema generation — sst shell loads the linked stage environment without inventing a second secrets path.


6. Stages as the DevOps Unit

In SST, a stage is an environment: a namespaced copy of the app.

StagePurposeDomain pattern
local / personalDaily sst devlocal Next URL; cloud resources for that stage
devShared previewdev.example.com
productionLive siteexample.com

  • Production is protected. Accidental sst remove should not wipe critical data, so the config uses protect and removal: "retain" on production (removal policies).
  • Non-production stages get CloudFront Basic Auth via an edge viewerRequest injection, so preview URLs are not public by default.
  • Credentials stay in the local AWS credential chain (profile or SSO session). SST deploys into that account and region. Next.js and secret resources are not created by clicking through the Console.

bash
aws sso login               # refresh AWS credentials
sst dev --stage local       # personal / local stage
sst deploy --stage dev      # shared preview
sst deploy --stage production


7. Local Workflow: sst dev, Live, and Mode Discipline

sst dev is the local control plane.

  • A watcher that deploys infrastructure changes.
  • The Next.js dev block — local Next, linked to the stage’s deployed resources.
  • Later, when the API module is imported, the container dev command and an optional tunnel to VPC resources.
  • Live — stub Lambdas in AWS that proxy to the local machine — matters most for Lambda-heavy stages, including the Function / API Gateway pattern in the backend APIs note. It is less central while this site only imports OpenNext.

sst dev belongs on a personal or local stage. Shared dev and production get sst deploy. A shared URL means deploying a real stage. Debugging a Lambda handler with breakpoints means Live on a personal stage — with VS Code Auto Attach when debugging Node handlers.

Failure: flipping the same stage between Live stubs and real deploys. If the CLI is killed, stubs can remain and remote invokes time out waiting for the local machine (Live quirks).



8. Production Safety and the CI Boundary

IaC only helps if state and process stay honest.

  • Do not Console-edit resources SST manages. SST applies diffs from config against state; manual edits drift that state (Basics).
  • Protect production with protect and removal: "retain". The Postgres used by the API path is an external URL via sst.Secret, not an sst.aws.Postgres this config created.
  • Verify, then deploy in CI. GitHub Actions runs install, lint, format check, typecheck, tests, and build first. Only after those checks succeed does the workflow run sst deploy for dev or production.
  • Local deploy:dev / deploy scripts remain useful for intentional one-off deploys. The default path is CI: green checks, then deploy.
  • The SST Console is another way to get git-push autodeploy. This site uses GitHub Actions for the verify-then-sst deploy sequence instead.

Tenant isolation and SaaS boundary design are a different problem — see Building a Multi-Tenant Backend with Hono, Better Auth, Drizzle, and Postgres RLS.



9. Mental Model

text
sst.config.ts
  → packages/infra/nextjs   (imported today)
      → Nextjs / OpenNext + edge Basic Auth secrets
  → packages/infra/api      (defined, not imported)
      → Vpc / Cluster / Service / Router + linked secrets
  → link (when a consumer is linked)
      → Resource.* in app runtime
      → sst shell for CLIs

stages: local | dev | production
local: sst dev
ci: checks → tests → build → sst deploy --stage …

SST keeps AWS infrastructure as reviewable TypeScript beside the app. Terraform, CDK, or Pulumi remain the better fit when the problem is org-wide platform ownership. For current component and CLI details, start at the SST docs. For the Hono API on Lambda or Fargate, continue with Backend APIs with Hono, Drizzle, Zod OpenAPI, and SST.


Recap Q&A

Read the next note
SOLID as Change Isolation