Skip to content

Infrastructure Intents

Infrastructure intents let you declare what resources your application needs without specifying provider-specific details. VentureKit translates intents into AWS resources (RDS, S3, SQS, ElastiCache, etc.) at deploy time.

Traditional IaC (Infrastructure as Code) requires you to know AWS service names, instance types, and configuration parameters. Intents abstract this:

  • CDK updates only require updating @venturekit/infra — consumer code stays the same
  • Stable API for consuming projects — intents don’t change when CDK does
  • Simplified configuration — no need to know AWS instance types

Define what resources exist once in vk.config.ts (names, types, purposes). Then override sizing and behavior per environment in config/dev.ts and config/prod.ts.

import { defineVenture } from '@venturekit/infra';
import { base } from './config/base';
import { dev } from './config/dev';
import { prod } from './config/prod';
export default defineVenture({
base,
databases: [
{ id: 'main', type: 'postgres', name: 'app' },
],
storage: [
{ id: 'uploads', purpose: 'uploads' },
],
auth: [
{ id: 'main', signInWith: ['email'], allowSignUp: true },
],
queues: [
{ id: 'jobs', type: 'standard' },
],
envs: { dev, prod },
});

The full list of intent arrays on defineVenture() (all optional): databases, storage, auth, queues, caches, schedules, functions, monitoring, secrets, envVars, domains, notify, events, billing. Every one of them can be overridden per environment by id.

Each env config overrides only what differs. Items are matched by id and shallow-merged with the shared definition:

config/dev.ts
import type { EnvConfigInput } from '@venturekit/core';
export const dev: EnvConfigInput = {
preset: 'free',
databases: [
{ id: 'main', size: 'small' },
],
};
config/prod.ts
import type { EnvConfigInput } from '@venturekit/core';
export const prod: EnvConfigInput = {
preset: 'medium',
databases: [
{ id: 'main', size: 'large', highAvailability: true, backups: true },
],
storage: [
{ id: 'uploads', cdn: true },
],
queues: [
{ id: 'jobs', deadLetterQueue: true },
],
};

At deploy time, VentureKit merges the shared definitions with the active environment’s overrides and provisions the corresponding AWS resources.

{
id: 'main-db',
type: 'postgres', // 'postgres' | 'mysql'
size: 'small', // 'small' | 'medium' | 'large' | 'xlarge'
name: 'app_main',
highAvailability: true, // Multi-AZ
backups: true,
backupRetentionDays: 7,
encrypted: true,
performanceInsights: true,
proxy: true, // opt-in RDS Proxy — see below; `true` or the tunables object
}
  • proxy puts an RDS Proxy in front of the instance. It is off by default and costs money — about $0.015 per vCPU-hour of the target instance, billed for at least 2 vCPUs (≈ $22/month per proxied database) on top of the instance. What it buys is connection pooling across Lambda containers: each warm container holds its own pg pool (DB_POOL_MAX, default 4 in Lambda), so a burst of N containers opens up to 4·N connections against max_connections (~100 on a small instance); the proxy multiplexes them onto a bounded set and absorbs Multi-AZ failover. Consider it once a stage regularly runs more than ~20 concurrent containers against a small class, or before highAvailability. Wiring is transparent — DB_HOST becomes the proxy endpoint (same port, same credentials secret, TLS required; the vendored RDS CA covers proxy endpoints), the Lambda security group is admitted to the proxy’s and the proxy’s to the instance’s, while the in-VPC migration runner keeps talking to the instance directly. The object form tunes it: { idleClientTimeoutSeconds: 1800, maxConnectionsPercent: 90, borrowTimeoutSeconds: 30 } (defaults shown).
{
id: 'uploads',
purpose: 'uploads', // 'uploads' | 'assets' | 'backups' | 'logs'
cdn: true, // Enable CloudFront
cdnDomain: 'cdn.example.com',
versioned: true,
corsOrigins: ['https://app.example.com'],
}

Declares a Cognito User Pool without exposing Cognito configuration. Two shapes share the auth array: an owned pool this venture creates, or an external pool another venture owns whose tokens this venture verifies.

// Owned pool — the common case
{
id: 'main',
signInWith: ['email'], // Array<'email' | 'phone' | 'username'>
allowSignUp: true,
mfa: 'optional', // 'off' (default) | 'optional' | 'required' — authenticator app (TOTP)
passwordStrength: 'strong', // 'standard' | 'strong'
clients: [{ name: 'web' }, { name: 'mobile' }], // one app client per entry; default: a single 'default' client
tokenValidity: { accessMinutes: 15, idMinutes: 15, refreshDays: 365 },
customAttributes: ['plan'], // your own custom:* claims — feature-implied ones are added for you
tenancy: true, // multi-tenant pool: auto-declares custom:tenantId + custom:tenantRoles
federated: ['google', 'linkedin'], // 'google' | 'facebook' | 'apple' | 'linkedin'
passkeys: { relyingPartyId: 'example.com', userVerification: 'preferred' }, // WebAuthn; essentials/plus plans only
adminRoutes: ['/auth/**', '/admin/users/**', 'fn:cognito-user-migration'], // Lambdas allowed to call Cognito admin APIs
customSender: { email: 'auth-mailer' }, // functions[] id that becomes the CustomEmailSender trigger
}
// External pool — trust a pool owned by another venture (same account/region/stage)
{
id: 'staff',
external: { project: 'control-plane', intent: 'main' }, // intent defaults to 'main'
adminRoutes: ['/staff/**'], // same semantics as on an owned pool
}
  • Scopes are not declared here. Authorization is enforced server-side (scopes: [...] on a handler) — cookie’d ID tokens never carry OAuth scope claims, so there are no Cognito resource servers to configure.
  • MFA is always an authenticator app. SMS is never enabled and email OTP is not offered yet. Enrolment/verification helpers live in @venturekit/auth/server (beginTotpSetup, confirmTotpSetup, respondToSoftwareTokenMfa, …) — see the Authentication guide.
  • federated does not enable the Hosted UI: your SPA does the OAuth dance with the provider, posts the resulting token to your API, and your handler calls signInAsFederatedUser(). Each provider gets a Secrets Manager placeholder at venturekit/<project>/<stage>/auth/<id>/<provider> (exposed as COGNITO_FEDERATED_<PROVIDER>_SECRET_ARN) that you populate after the first deploy, plus auto-declared federated_provider and <provider>Sub custom attributes.
  • passkeys adds WebAuthn as a sign-in factor — opt-in, no extra AWS cost, but only on the essentials / plus feature plan (the medium / large presets); on a lite pool the deploy fails at synth with a fix-it message. relyingPartyId is the apex domain every enrolled passkey is bound to (example.com covers app.example.com and admin.example.com; changing it invalidates every passkey), and it must be a bare domain, not a URL. userVerification: 'required' insists on the device’s biometric / PIN; 'preferred' (default) lets the authenticator decide. Provisions the pool’s relying party, the choice-based USER_AUTH flow (password stays enabled) and ALLOW_USER_AUTH on every app client; @venturekit/auth/server’s beginPasskeyRegistration / completePasskeySignIn family drives the flows — see the Authentication guide.
  • adminRoutes names the Lambdas allowed to call Cognito’s admin APIs on the pool (AdminCreateUser, AdminSetUserPassword, AdminInitiateAuth, …) — the ones running signUpUser, signInAsFederatedUser, adminInviteUser, the passwordless and forgot-password flows. When declared, vk deploy provisions a second execution role carrying those grants and attaches it only to the matching functions; every other Lambda keeps the shared role without Cognito admin powers, so a bug in an unrelated public route cannot become “create an admin user”. Entries match the route’s API path (* one segment, ** any number — '/auth/**' covers every auth route); standalone functions[], queues[] consumers and schedules[] handlers match by id as 'fn:<id>'. Omitted, you get the historical layout — one shared role with the admin grants — and a synth-time warning. Err on the side of '/auth/**' plus every admin surface that manages users: an excluded route fails with AccessDeniedException at first call.
  • customAttributes are created as mutable strings. role is auto-declared unless tenancy: true, in which case tenantId + tenantRoles replace it (resolveAuthCustomAttributes() in @venturekit/core is the exact rule).
  • customSender routes the messages Cognito would send itself (verification and forgot-password codes, MFA OTPs, temporary passwords) through your own src/functions/<id>.ts handler. vk deploy provisions the KMS key (alias/<project>-<stage>-<id>-cognito-custom-sender), the invoke permission and the pool’s LambdaConfig; the key ARN reaches every Lambda as AUTH_<ID>_CUSTOM_SENDER_KMS_KEY_ARN. A custom sender takes over every message of its channel. Declaring sms also provisions the pool’s SNS SMS role. Ignored under vk dev (cognito-local cannot invoke Lambdas).
  • Runtime env vars. Every intent, owned or external, is exposed as AUTH_<ID>_USER_POOL_ID, AUTH_<ID>_APP_CLIENT_ID, AUTH_<ID>_REGION (+ AUTH_<ID>_ENDPOINT under vk dev), with <ID> upper-snaked. The first declared intent is also the canonical COGNITO_* set that loadAuthServerConfig() reads by default.
  • The pool’s per-MAU feature plan is an environment setting, not an intent field: identity: { featurePlan: 'lite' | 'essentials' | 'plus' } in config/<env>.ts (presets choose lite or essentials; plus — threat protection, no free tier — is always opt-in).
  • The local pool under vk dev is a cognito-local container provisioned with the same schema, so locally issued JWTs carry the same custom claims. tokenValidity is not honoured locally.
{
id: 'email-queue',
type: 'standard', // 'standard' | 'fifo'
deadLetterQueue: true,
retentionDays: 7,
visibilityTimeoutSeconds: 60,
}
{
id: 'session-cache',
type: 'redis', // 'redis' | 'memcached'
size: 'small', // 'small' | 'medium' | 'large' | 'xlarge'
clustered: false,
}

The handler is auto-discovered at src/crons/<id>.ts; handler only needs setting when the file lives elsewhere.

// Rate-based
{
id: 'cleanup-job', // → src/crons/cleanup-job.ts
schedule: { rate: '1 hour' }, // "5 minutes", "1 hour", "1 day"
enabled: true, // default true; false never registers the job (locally too)
memorySize: 256, // MB, default 256
timeout: 60, // seconds, default 60
}
// Cron-based (EventBridge 6-field form)
{
id: 'daily-report',
schedule: { cron: '0 8 * * ? *' }, // 8:00 AM UTC daily
}

vk dev runs the same schedules locally (per-env overrides included) and exposes each handler at POST /_dev/invoke/cron/{id}; pass --no-crons to keep the scheduler dormant while still allowing manual invocation.

A standalone Lambda that is not a route, queue consumer or cron — saga workers, data processors, Cognito custom senders. Discovered from src/functions/: src/functions/process-orders.ts → {project}-{stage}-fn-process-orders, src/functions/order/create-order.ts → ...-fn-order-create-order. Invoke with invoke({ function: 'process-orders' }) from @venturekit/runtime/patterns.

{
id: 'process-orders', // matches the file path under src/functions/
memorySize: 512, // default 256
timeout: 120, // seconds, default 30
description: 'Nightly order reconciliation',
}

A domain-event bus (EventBridge custom bus) plus the rules that route matched events to in-venture targets — the pub/sub complement to point-to-point queues. One published event can fan out to many subscribers, including subscribers in other ventures.

// Owner: creates bus {project}-{stage}-domain, grants events:PutEvents,
// injects VENTURE_EVENT_BUS_NAME + EVENT_BUS_DOMAIN_NAME
{
id: 'domain',
source: 'control-plane', // default source stamped on published events (defaults to the project name)
subscriptions: [
{
id: 'tenant-lifecycle',
detailTypes: ['tenant.created', 'tenant.suspended'],
target: { queue: 'tenant-events' }, // a queues[] id — or { function: '<functions[] id>' }
},
],
}
// Subscriber only: attach rules to a bus another venture owns (same account + region; owner deploys first)
{
id: 'platform',
external: { busName: 'control-plane-prod-domain' },
subscriptions: [
{ id: 'schools', sources: ['control-plane'], pattern: { detail: { vertical: ['school'] } }, target: { function: 'sync-school' } },
],
}

Handlers publish with publishEvent() / publishEvents() from @venturekit/runtime/patterns; queue targets receive the event as an ordinary SQS message (at-least-once), function targets are invoked asynchronously.

Custom domains for the HTTP API (default) or the WebSocket API. VentureKit does not assume Route 53 — the default dns: 'external' creates no DNS records.

{
id: 'api',
domain: 'api.example.com',
target: 'http', // 'http' (edge stack) | 'websocket' (requires websocket.enabled + src/ws/)
dns: 'external', // 'external' (default) | 'route53' (requires hostedZoneId)
hostedZoneId: 'Z123ABCEXAMPLE', // route53 mode only
certificateArn: 'arn:aws:acm:...', // optional — see below
basePath: '/', // API mapping path, default '/'
}

Certificate resolution: with certificateArn the cert is imported; with dns: 'route53' a DNS-validated cert is issued against the zone; with external DNS and no ARN, vk deploy finds or requests a DNS-validated cert in ACM, prints the validation CNAMEs, and only creates the custom domain once ACM reports ISSUED — the API stays reachable on its execute-api URL meanwhile, and a domain that is skipped is not published as edgeKeys.apiCustomDomain / VENTURE_WS_CUSTOM_DOMAIN. vk cert request does the same up front and writes the ARN into your config so CI never depends on the lookup. WebSocket certs are issued in the deploy region (not us-east-1).

Declares a payment provider the app transacts through. No credentials in config: VentureKit provisions a placeholder secret in Secrets Manager and injects its ARN; an operator fills in the real values after the first deploy.

{
id: 'main',
provider: 'zazu', // provisions {apiKey, accountId, webhookSecret}; injects ZAZU_SECRET_ARN
}

The runtime client lives in @venturekit/billing.

secrets: [
{ id: 'stripe-key', store: 'secretsmanager', encrypted: true }, // 'ssm' | 'secretsmanager'
],
envVars: [
{ name: 'FEATURE_FLAGS', value: 'beta' }, // plain value…
{ name: 'STRIPE_KEY', secretRef: 'stripe-key' }, // …or a reference to a secrets[] id (mutually exclusive)
],

envVars are injected into every Lambda; secrets become outputs.secrets['<id>'].arn / .name.

{
id: 'api-health',
dashboard: true, // CloudWatch dashboard
notificationArn: 'arn:aws:sns:...',
alarms: [
{
name: 'high-5xx',
metric: '5XXError',
namespace: 'AWS/ApiGateway', // default AWS/Lambda
threshold: 10,
comparison: 'gt', // 'gt' | 'gte' | 'lt' | 'lte'
evaluationPeriods: 2, // default 1
periodSeconds: 300, // default 300
statistic: 'Sum', // 'Average' | 'Sum' | 'Minimum' | 'Maximum' | 'SampleCount'
dimensions: { ApiName: 'my-api' },
},
],
}

Declares the application’s transactional-messaging surface (email / WhatsApp / SMS / push). VentureKit provisions an SES identity + configuration set, a bounce/complaint SNS topic, an outbox table in your Postgres database, and a dispatcher cron — your handlers just call notify.enqueue(...) (see the @venturekit/notify package).

{
id: 'main',
channels: ['email', 'whatsapp'], // 'email' | 'whatsapp' | 'sms' | 'push'
defaultFrom: 'no-reply@app.example.com',
domain: 'app.example.com', // SES DomainIdentity with DKIM signing
sesIdentity: 'own', // 'own' | 'shared' — omit and vk deploy decides (see below)
hostedZoneId: 'Z123ABCEXAMPLE', // auto-publish DKIM CNAMEs in Route 53 (ignored when shared)
tracking: 'postgres', // 'postgres' (default) | 'none'
databaseId: 'main', // outbox database — required only with several databases[]
clientModule: './src/lib/notify.ts', // module exporting `notify` (default shown)
dispatchRateMinutes: 1, // dispatcher poll rate
dispatchBatchSize: 50, // notifications claimed per invocation
whatsapp: {
phoneNumberId: '1234567890', // Meta Cloud API Phone Number ID
wabaId: '9876543210', // WhatsApp Business Account ID — needed by the template-management APIs
apiVersion: 'v21.0',
webhooks: true, // also provision the App Secret + verify-token secrets for inbound webhooks
},
}

Most projects need exactly one notify intent (id: 'main'). The WhatsApp access token is stored in a Secrets Manager secret created by the stack — populate it after the first deploy. The runtime client that consumes this intent ships in the free @venturekit/notify package.

  • sesIdentity — an SES identity is one per account + region, so two ventures sending from the same domain cannot both create it. 'shared' creates no EmailIdentity (and no DKIM records) but still provisions this venture’s own configuration set, events topic and DLQs. Leave it unset and vk deploy’s pre-flight asks SES whether the identity exists and whether this venture’s <project>-<stage>-messaging stack owns it. Under strict dataSafety the identity is retained on stack deletion.
  • whatsapp.webhooks — opt-in. Provisions two extra secrets whose ARNs reach Lambdas as VENTURE_NOTIFY_WHATSAPP_APP_SECRET and VENTURE_NOTIFY_WHATSAPP_VERIFY_TOKEN_SECRET, so createMetaWhatsAppWebhookHandler() needs no credential arguments. Declaring it without 'whatsapp' in channels is a synth-time error. wabaId is exported as VENTURE_NOTIFY_WHATSAPP_WABA_ID.
SizeDB InstanceCache Node
smalldb.t3.smallcache.t3.small
mediumdb.r6g.largecache.r6g.large
largedb.r6g.xlargecache.r6g.xlarge
xlargedb.r6g.2xlargecache.r6g.2xlarge

After infrastructure is provisioned, outputs are available in route handlers via ctx.intentOutputs:

import { handler } from '@venturekit/runtime';
import type { IntentOutputs } from '@venturekit/core';
export const main = handler(async (_body, ctx, logger) => {
const outputs = ctx.intentOutputs as IntentOutputs;
// Database
const dbEndpoint = outputs.databases['main-db'].endpoint;
const dbPort = outputs.databases['main-db'].port;
const dbSecretArn = outputs.databases['main-db'].secretArn;
// Storage
const bucketName = outputs.storage['uploads'].bucketName;
const cdnUrl = outputs.storage['uploads'].cdnUrl;
// Queue
const queueUrl = outputs.queues['email-queue'].queueUrl;
// Cache
const cacheEndpoint = outputs.caches['session-cache'].endpoint;
// Auth
const userPoolId = outputs.auth['main'].userPoolId;
const clientId = outputs.auth['main'].clientId;
// Functions, event buses, secrets
const fnArn = outputs.functions['process-orders'].functionArn;
const busName = outputs.eventBuses['domain'].busName;
const secretArn = outputs.secrets['stripe-key'].arn;
// Notify (SES + dispatcher)
const sesConfigSet = outputs.notify['main'].configurationSetName;
const bounceTopicArn = outputs.notify['main'].eventsTopicArn;
// Billing (placeholder credential secret)
const zazuSecretArn = outputs.billing['main'].secretArn;
return { dbEndpoint, bucketName, queueUrl };
});

Intent outputs are serialized into the VENTURE_INTENT_OUTPUTS Lambda environment variable and hydrated automatically into ctx.intentOutputs at runtime.