sst-infra
SST v3 (Ion)
SST v3 is a framework for defining and deploying full-stack applications on AWS. It uses Pulumi under the hood but exposes a simpler component API. All infrastructure is defined in TypeScript in sst.config.ts.
Important: SST v3 vs v2
SST v3 (Ion) is a complete rewrite from v2. Key differences:
| Aspect | SST v2 (Constructs) | SST v3 (Ion) |
|---|---|---|
| Engine | AWS CDK | Pulumi/Terraform |
| Config | stacks/ directory |
Single sst.config.ts |
| Components | new Table(stack, ...) |
new sst.aws.Dynamo(...) |
| Subscribers | .addConsumers() |
.subscribe() |
| Linking | bind: [...] |
link: [...] |
| SDK import | import { Config } from 'sst/node/config' |
import { Resource } from 'sst' |
Never generate SST v2 code. Always use the v3 patterns below.
sst.config.ts Structure
/// <reference path="./.sst/platform/config.d.ts" />
export default $config({
app(input) {
return {
name: 'thanks-card',
removal: input.stage === 'production' ? 'retain' : 'remove',
protect: ['production'].includes(input.stage),
home: 'aws',
providers: {
aws: {
region: 'ap-northeast-1',
},
},
}
},
async run() {
// Define resources here
const table = new sst.aws.Dynamo('MainTable', {
fields: {
pk: 'string',
sk: 'string',
gsi1pk: 'string',
gsi1sk: 'string',
},
primaryIndex: { hashKey: 'pk', rangeKey: 'sk' },
globalIndexes: {
'gsi1pk-gsi1sk-index': {
hashKey: 'gsi1pk',
rangeKey: 'gsi1sk',
},
},
stream: 'new-and-old-images',
})
const api = new sst.aws.ApiGatewayV2('Api')
api.route('$default', {
handler: 'apps/api/src/index.handler',
link: [table],
})
return { apiUrl: api.url }
},
})
Key rules for sst.config.ts
- The
run()function isasync— you can useawait. - Do not
importAWS provider packages directly. SST manages them via thesst.aws.*namespace. - Return an object from
run()to expose outputs (URLs, ARNs, etc.). - Use
input.stageinapp()to configure stage-specific behavior.
AWS Components
Function
const fn = new sst.aws.Function('MyFunction', {
handler: 'src/handler.main',
runtime: 'nodejs22.x',
timeout: '30 seconds',
memory: '256 MB',
link: [table, secret],
environment: {
CUSTOM_VAR: 'value',
},
permissions: [
{
actions: ['ses:SendEmail'],
resources: ['*'],
},
],
})
ApiGatewayV2
const api = new sst.aws.ApiGatewayV2('Api')
// Single handler for all routes (monolithic Hono app)
api.route('$default', {
handler: 'apps/api/src/index.handler',
link: [table],
timeout: '30 seconds',
})
// Or individual routes
api.route('GET /users', 'src/routes/users.list')
api.route('POST /cards', {
handler: 'src/routes/cards.create',
link: [table],
})
// Custom domain
api.route('$default', 'src/index.handler')
// api.url gives you the endpoint URL
Dynamo
const table = new sst.aws.Dynamo('MainTable', {
fields: {
pk: 'string',
sk: 'string',
gsi1pk: 'string',
gsi1sk: 'string',
},
primaryIndex: { hashKey: 'pk', rangeKey: 'sk' },
globalIndexes: {
'gsi1pk-gsi1sk-index': {
hashKey: 'gsi1pk',
rangeKey: 'gsi1sk',
},
},
// Enable DynamoDB Streams
stream: 'new-and-old-images',
// Transform underlying Pulumi resource
transform: {
table: {
billingMode: 'PAY_PER_REQUEST',
pointInTimeRecovery: { enabled: true },
},
},
})
// Subscribe to stream events
table.subscribe('AiWorker', {
handler: 'apps/api/src/handlers/ai-worker.handler',
link: [table],
timeout: '5 minutes',
// Filter to only INSERT events
filters: [
{
eventName: ['INSERT'],
},
],
})
StaticSite
const web = new sst.aws.StaticSite('Web', {
path: 'apps/web',
build: {
command: 'pnpm build',
output: 'dist',
},
environment: {
VITE_API_URL: api.url,
},
})
Cron
new sst.aws.Cron('DailyNotifier', {
schedule: 'cron(0 0 * * ? *)', // Every day at 00:00 UTC
function: {
handler: 'apps/api/src/handlers/notifier.handler',
link: [table],
timeout: '5 minutes',
},
})
// Rate expression
new sst.aws.Cron('Heartbeat', {
schedule: 'rate(5 minutes)',
function: 'src/heartbeat.handler',
})
Queue (SQS)
const dlq = new sst.aws.Queue('DeadLetterQueue')
const queue = new sst.aws.Queue('ProcessingQueue', {
dlq: dlq.arn,
})
queue.subscribe('Processor', {
handler: 'src/processor.handler',
link: [table],
timeout: '5 minutes',
})
Bucket (S3)
const bucket = new sst.aws.Bucket('Uploads', {
access: 'cloudfront', // For CDN distribution
})
CognitoUserPool
const userPool = new sst.aws.CognitoUserPool('Auth', {
triggers: {
postConfirmation: 'src/auth/post-confirmation.handler',
},
})
const client = userPool.addClient('WebClient')
For the full component API reference, see references/sst-components.md.
Resource Linking
The link property is SST's mechanism for granting a function access to other resources. It does two things:
- Grants IAM permissions automatically (least-privilege)
- Injects resource properties as environment variables accessible via the SDK
// In sst.config.ts
const table = new sst.aws.Dynamo('MainTable', { ... })
const bucket = new sst.aws.Bucket('Uploads')
new sst.aws.Function('MyFunction', {
handler: 'src/index.handler',
link: [table, bucket], // Function can access both
})
// In function code
import { Resource } from 'sst'
const tableName = Resource.MainTable.name // DynamoDB table name
const bucketName = Resource.Uploads.name // S3 bucket name
The Resource object is fully typed — autocomplete works based on what's linked.
Secrets
Secrets are managed via the CLI and stored encrypted per stage:
# Set a secret
sst secret set BedrockApiKey sk-abc123
# Set for a specific stage
sst secret set BedrockApiKey sk-prod456 --stage production
# List secrets
sst secret list
# Remove a secret
sst secret remove BedrockApiKey
// In sst.config.ts
const bedrockKey = new sst.Secret('BedrockApiKey')
new sst.aws.Function('AiWorker', {
handler: 'src/ai-worker.handler',
link: [bedrockKey],
})
// In function code
import { Resource } from 'sst'
const apiKey = Resource.BedrockApiKey.value
Stages
Stages are isolated deployments of the same app. Each stage has its own resources.
# Local development (live Lambda with hot reload)
sst dev
# Deploy to a named stage
sst deploy --stage staging
sst deploy --stage production
# Remove a stage entirely
sst remove --stage pr-123
Stage-aware configuration
app(input) {
return {
name: 'thanks-card',
// Keep resources on production delete; remove on dev
removal: input.stage === 'production' ? 'retain' : 'remove',
// Prevent accidental updates on production
protect: ['production'].includes(input.stage),
home: 'aws',
}
},
async run() {
// Stage-conditional resources
const isProd = $app.stage === 'production'
const table = new sst.aws.Dynamo('MainTable', {
// ...
transform: {
table: {
pointInTimeRecovery: { enabled: isProd },
},
},
})
}
The $app.stage variable is available in run() to branch on the current stage.
CLI Commands
| Command | Description |
|---|---|
sst dev |
Start local dev mode (live Lambda, hot reload) |
sst deploy |
Deploy to current stage |
sst deploy --stage X |
Deploy to named stage |
sst remove |
Remove all resources for current stage |
sst secret set K V |
Set an encrypted secret |
sst secret list |
List all secrets for current stage |
sst shell |
Open a shell with Resource env vars loaded |
sst shell -- cmd |
Run a command with Resource env vars |
sst console |
Open the SST Console dashboard |
sst diff |
Preview infrastructure changes |
Common Mistakes
- Importing provider packages in sst.config.ts — SST manages provider imports. Use
sst.aws.*components, not direct@pulumi/awsimports. - Using SST v2 patterns —
new Table(stack, ...),bind: [...],import { Config }are all v2. Usenew sst.aws.Dynamo(...),link: [...],import { Resource }. - Forgetting
link— Withoutlink, a function has no permissions and no access toResource.*. Every resource a function uses must be linked. - Hardcoding table names — Use
Resource.MyTable.namefrom the SST SDK, not hardcoded strings. This ensures stage isolation and correct IAM scoping. - Missing
streamon Dynamo — DynamoDB Streams are opt-in. You must setstream: 'new-and-old-images'(or other mode) before calling.subscribe(). - Using
sst removeon production — Theprotectandremoval: 'retain'settings inapp()guard against this, but always double-check the stage.