stacks/ts-cloud
publicClone
Push over the same URL. A password will not work: create a token under access tokens and use it in place of one.
Zero-dependency, modern infrastructure-as-code framework.
- .config
- .github
- .ts-cloud
- .vscode
- benchmarks
- docs
- examples
- images
- packages
- scripts
- test
- .editorconfig 147 B
- .gitattributes 12 B
- .gitignore 566 B
- bun.lock 27.1 KB
- bunfig.toml 165 B
- CHANGELOG.md 237.5 KB
- CLAUDE.md 1.6 KB
- cloud 57 B
- cloud.config.ts 4.9 KB
- deps.yaml 32 B
- docker-compose.yml 3.1 KB
- LICENSE.md 1.1 KB
- package.json 4.3 KB
- pantry.lock 550 B
- pickier.config.ts 1.4 KB
- README.md 25.7 KB
- tsconfig.json 1.2 KB

ts-cloud
Zero-dependency cloud infrastructure as TypeScript. A driver-based system — deploy production-ready infrastructure to AWS or Hetzner with the same config, no cloud SDK or CLI required.
Overview
ts-cloud is a modern infrastructure-as-code framework that lets you define cloud infrastructure using TypeScript configuration files. Your config is provider-agnostic; a pluggable cloud driver translates it into provider-native API calls. Unlike AWS CDK or Terraform, ts-cloud:
- Driver-Based Architecture - One
CloudDriverinterface, swappable backends. AWS and Hetzner drivers ship in the box. - Zero Cloud Dependencies - No AWS SDK, no Hetzner SDK, no CLIs. Direct, signed HTTPS API calls only.
- Type-Safe Configuration - Full TypeScript types for all resources
- Production-Ready Presets - 13 battle-tested infrastructure templates
- Bun-Powered - Lightning-fast builds and deployments
- CloudFormation Native - The AWS driver generates clean, reviewable CloudFormation templates
Cloud Drivers
Every provision, deploy, and teardown flows through the same CloudDriver interface — the CLI and your config never talk to a provider directly:
cloud.config.ts
│
▼
resolveCloudProvider(config) createCloudDriver({ config })
cloud.provider, or auto-detected ───────────────►┌──────────────────────────┐
from ssh.hosts / hetzner token │ CloudDriver │
│ │
│ provisionCompute / │
│ getComputeOutputs / │
│ uploadRelease / │
│ runRemoteDeploy / │
│ destroyCompute │
└────────────┬─────────────┘
│
┌──────────────┬──────────────┬─────────┴────────────┐
▼ ▼ ▼ ▼
┌────────────┐ ┌──────────────┐ ┌───────────┐ ┌─────────────┐
│ AwsDriver │ │ HetznerDriver│ │ SshDriver │ │LocalBoxDriver│
└────────────┘ └──────────────┘ └───────────┘ └─────────────┘| Driver | Compute | Deploys via | Release staging | Infrastructure model |
|---|---|---|---|---|
aws | EC2 (CloudFormation stacks or a lightweight single-box boot) | SSM AWS-RunShellScript | S3 deploy bucket | Clean, reviewable CloudFormation templates |
hetzner | Hetzner Cloud servers, cloud firewalls, private networks, native load balancers | SSH + cloud-init bootstrap | /var/ts-cloud/staging on the box | Direct Hetzner Cloud API calls |
ssh | A Linux host you already own (a Raspberry Pi, a colocated box) | SSH; the host is adopted, preflighted and bootstrapped in place | /var/ts-cloud/staging on the host | None: nothing is created or destroyed |
local-box | The machine ts-cloud runs on | Local shell | — | On-box management dashboard (box mode) |
New providers implement the same interface (packages/core/src/drivers/types.ts) and register with the factory — DNS stays provider-agnostic via the separate DnsProvider abstraction (Cloudflare, Porkbun, GoDaddy, Route53).
Choosing a provider
// cloud.config.ts
export default {
project: { name: 'My App', slug: 'my-app', region: 'us-east-1' },
cloud: {
provider: 'hetzner', // 'aws' (default) | 'hetzner'
},
// ...
}Resolution order: an explicit cloud.provider wins; otherwise a hetzner.apiToken in the config file selects the Hetzner driver; with neither, the AWS driver is used (backward compatible). The token itself is supplied at deploy time — HCLOUD_TOKEN / HETZNER_API_TOKEN in the environment, or hetzner.apiToken in the config.
Deploy to Hetzner
Set the provider, supply a token, deploy:
export HCLOUD_TOKEN="your-hetzner-cloud-api-token"
cloud deploy// cloud.config.ts
export default {
// ...
cloud: { provider: 'hetzner' },
}The Hetzner driver boots an Ubuntu 24.04 server behind a cloud firewall, registers your deploy SSH key, and provisions the stack over cloud-init (nginx, PHP-FPM, bun/node/deno runtimes, databases, caches — installed via pantry). Fleets (a private network, dedicated services box, N app servers, and a load balancer) are first-class too. Server state is pinned in storage/cloud/state/<stack>.json — commit it, and CI reuses the same box instead of provisioning a new one.
Optional config (all env-overridable):
| Config | Env | Default |
|---|---|---|
hetzner.apiToken | HCLOUD_TOKEN / HETZNER_API_TOKEN | — (required) |
hetzner.location | HCLOUD_LOCATION | fsn1 |
hetzner.image | HCLOUD_IMAGE | ubuntu-24.04 |
hetzner.sshPrivateKeyPath | HCLOUD_SSH_KEY | ~/.ssh/id_ed25519 |
hetzner.sshUser | HCLOUD_SSH_USER | root |
Deploy to AWS
AWS is the default driver — just standard credentials:
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_REGION="us-east-1"
cloud deployFull infrastructure (VPC, ALB, RDS, ElastiCache, CloudFront, …) is generated as CloudFormation; the lightweight Forge-style path boots a single tagged EC2 box and deploys over SSM with releases staged in S3.
Programmatic drivers
import { createCloudDriver } from 'ts-cloud'
const driver = createCloudDriver({ config }) // AwsDriver | HetznerDriver (LocalBoxDriver in box mode)
const outputs = await driver.getComputeOutputs({ config, environment: 'production' })
// outputs.appPublicIp, outputs.appInstanceId, outputs.deployStoragePath, ...Features
🌩️ Multi-Cloud by Design
One config, two production drivers today:
- AWS - CloudFormation-generated stacks or a lightweight single-EC2 boot, SSM-based deploys, S3 release staging
- Hetzner - Cloud servers + firewalls + private networks + load balancers over the Hetzner Cloud API, SSH deploys, cloud-init bootstrap
See Cloud Drivers for the architecture and per-provider setup.
🚀 Configuration Presets
Skip the boilerplate with production-ready presets for common architectures:
- Static Sites - S3 + CloudFront for SPAs and static websites
- Node.js Servers - EC2 + ALB + RDS + Redis for traditional apps
- Serverless Apps - ECS Fargate + ALB + DynamoDB for scalable services
- Full-Stack Apps - Complete frontend + backend + database stack
- API Backends - API Gateway + Lambda + DynamoDB for serverless APIs
- WordPress - Optimized WordPress hosting with RDS + EFS + CloudFront
- JAMstack - Modern static sites with Lambda@Edge for SSR
- Microservices - Multi-service architecture with service discovery
- Real-time Apps - WebSocket API + Lambda + DynamoDB Streams
- Data Pipelines - Kinesis + Lambda + S3 + Athena + Glue for ETL
- ML APIs - SageMaker + API Gateway for ML inference
- Traditional Web Apps - Session-based apps with EFS + Redis + ALB
🛠️ Infrastructure Builders
Complete CloudFormation template builders for:
- Network - VPC, subnets, NAT gateways, routing, security groups
- Storage - S3 buckets with versioning, encryption, lifecycle rules, EFS
- Compute - EC2 Auto Scaling, ECS Fargate, Lambda functions
- Database - RDS (PostgreSQL/MySQL), DynamoDB with streams and GSIs
- Cache - ElastiCache Redis/Memcached with replication
- CDN - CloudFront distributions with custom domains and Lambda@Edge
- API Gateway - HTTP, REST, and WebSocket APIs
- Queue - SQS queues with dead letter queues
- Messaging - SNS topics and subscriptions
- Monitoring - CloudWatch dashboards, alarms, and log groups
- Security - ACM certificates, WAF rules, security groups
✉️ Mail Server
A mail server your project actually owns, from one line of config:
managedServices: { mail: true }- SMTP, IMAP, DKIM signing, ACME TLS, spam scoring, and a webmail UI, provisioned as a hardened systemd unit- One binary, two modes - production gets a real MTA; every other environment gets a catcher that accepts everything and delivers nothing, on mailpit's ports so anything pointed at mailpit is already pointed at this
- No parser gap - the trap and the server are the same program, so a message that renders in development has been through the code production runs
- Wired for you - every site on the box gets
MAIL_*in its.envfrom the same resolution that configured the listeners, so the two cannot drift - DNS printed, never published - MX, SPF, DMARC and DKIM are handed to you, because a wrong MX does not fail, it quietly routes somebody's mail elsewhere
See the mail server documentation.
🔒 Security Posture and Deployment Gates
Persistent security findings and policy gates prevent unsafe releases:
- 35+ Secret Patterns - AWS keys, API tokens, private keys, database credentials
- Container Supply Chain - Local Trivy scanning, CycloneDX SBOMs, vulnerability summaries, and SLSA provenance
- Environment Policies - Block, warn, or record by severity and scanner health
- Auditable Remediation - Assignment, comments, expiring waivers, recurrence, and decision history
- Posture Center - Responsive dashboard for findings, scanner health, policy editing, review, and export
# Scan for secrets before deploying
cloud deploy:security-scan --source ./dist
# Deploy with automatic security scanning
cloud deploy # Scans automatically before deploymentSee the security posture center documentation for policy, scanner, waiver, and release-artifact behavior.
💸 Spend Management
Cloud bills are a trailing indicator, so ts-cloud does not wait for one. Usage is metered from telemetry, priced locally, and capped before the money is spent:
- Soft & hard caps - A configurable ladder from notify, through blocking builds and deployments, to throttling traffic. Nothing it does deletes data, and every action records what it takes to undo it
- Forecasting with confidence - Projections carry a trust score, and a forecast built from ten minutes of a month never enforces
- Anomaly detection - Median/MAD against a per-phase seasonal baseline, so a normal Monday morning is not an incident
- Usage API -
GET /api/v1/usageand/spend/allowanceanswer "can I afford this deploy?" for CI, agents, and scripts - Provider-neutral - Works identically on AWS, Hetzner, and a local box, including providers with no billing API at all
cloud usage # spend and headroom
cloud budget:create --name 'Production' --hard 500 --soft 400 # starts in dry run
cloud spend:check --apply # run one cycle now
cloud spend:work # or run the loop continuouslySee the spend management documentation.
🛡️ Edge Protection
- L3/L4 - nftables and sysctl hardening against SYN floods, connection exhaustion, slow-loris, and single-source hammering, validated before it is applied
- L7 rate limiting - Token bucket and sliding window, per IP, header, cookie, path, or globally, with adaptive tightening driven by traffic shape rather than volume alone
- WAF - OWASP CRS via zig-waf, in detection mode by default
- Recursion protection - Automatic and on by default: the runtime inspects every invocation and propagates the chain on outbound fetch, catching A→B→A loops a depth counter misses
- Attack mode - Challenge every visitor, block or allow CIDRs, or pause mitigation entirely; all time-boxed so nothing is left on by accident
Kernel filtering, the WAF (detection-only), and recursion protection are applied to every deploy without opting in.
See the edge protection documentation.
☁️ Direct Provider Integration
No SDK, no CLI - pure signed HTTPS API calls against the providers themselves:
- AWS - Signature V4 calls: CloudFormation (CreateStack, UpdateStack, DeleteStack, DescribeStacks), S3 (PutObject, multipart upload, sync directory), CloudFront (cache invalidations with wait support). Credentials resolve from env vars, ~/.aws/credentials, or IAM roles
- Hetzner - Bearer-token Cloud API client with pagination, action polling, firewalls, private networks, load balancers, and SSH key management. Token resolves from
HCLOUD_TOKEN/HETZNER_API_TOKENorhetzner.apiToken
Quick Start
Installation
bun add ts-cloudYour First Deployment
Create a cloud.config.ts:
import { createStaticSitePreset } from 'ts-cloud/presets'
export default createStaticSitePreset({
name: 'My Website',
slug: 'my-website',
domain: 'example.com',
})Deploy:
bun run cloud deployThat's it! You now have:
- ✅ S3 bucket with static website hosting
- ✅ CloudFront CDN with HTTPS
- ✅ Route53 DNS configuration
- ✅ ACM SSL certificate
The same config deploys to Hetzner by setting cloud.provider: 'hetzner' and exporting HCLOUD_TOKEN — see Cloud Drivers.
More Examples
Full-Stack Application
import { createFullStackAppPreset } from 'ts-cloud/presets'
export default createFullStackAppPreset({
name: 'My App',
slug: 'my-app',
domain: 'app.example.com',
apiSubdomain: 'api.example.com',
})Includes:
- Frontend: S3 + CloudFront
- Backend: ECS Fargate with auto-scaling
- Database: PostgreSQL RDS with Multi-AZ
- Cache: Redis ElastiCache
- Queue: SQS for background jobs
Serverless API
import { createApiBackendPreset } from 'ts-cloud/presets'
export default createApiBackendPreset({
name: 'My API',
slug: 'my-api',
domain: 'api.example.com',
})Includes:
- API Gateway HTTP API
- Lambda functions with auto-scaling
- DynamoDB tables with on-demand billing
- CloudWatch monitoring and alarms
Configuration
Extending Presets
You can extend any preset with custom configuration:
import { createNodeJsServerPreset, extendPreset } from 'ts-cloud/presets'
export default extendPreset(
createNodeJsServerPreset({
name: 'My App',
slug: 'my-app',
}),
{
infrastructure: {
compute: {
server: {
instanceType: 't3.large', // Upgrade instance type
autoScaling: {
max: 20, // Increase max instances
},
},
},
},
}
)Composing Presets
Combine multiple presets:
import { composePresets, createStaticSitePreset, createApiBackendPreset } from 'ts-cloud/presets'
export default composePresets(
createStaticSitePreset({ name: 'Frontend', slug: 'frontend', domain: 'example.com' }),
createApiBackendPreset({ name: 'Backend', slug: 'backend' }),
{
// Custom overrides
infrastructure: {
monitoring: {
alarms: [{ metric: 'Errors', threshold: 10 }],
},
},
}
)Advanced Usage
Custom CloudFormation
Generate templates programmatically:
import { CloudFormationBuilder } from 'ts-cloud/cloudformation'
const builder = new CloudFormationBuilder(config)
const template = builder.build()
console.log(JSON.stringify(template, null, 2))Direct AWS API Calls
Use the AWS clients directly:
import { CloudFormationClient, S3Client, CloudFrontClient } from 'ts-cloud/aws'
// CloudFormation
const cfn = new CloudFormationClient('us-east-1')
await cfn.createStack({
stackName: 'my-stack',
templateBody: JSON.stringify(template),
})
// S3
const s3 = new S3Client('us-east-1')
await s3.putObject({
bucket: 'my-bucket',
key: 'file.txt',
body: 'Hello World',
})
// CloudFront
const cloudfront = new CloudFrontClient()
await cloudfront.createInvalidation({
distributionId: 'E1234567890',
paths: ['/*'],
})Direct Hetzner API Calls
The Hetzner driver's Cloud API client is exported too:
import { HetznerClient } from 'ts-cloud'
const hcloud = new HetznerClient({ apiToken: process.env.HCLOUD_TOKEN! })
const servers = await hcloud.listServers() // fully paginated
const { firewall } = await hcloud.createFirewall({
name: 'web-fw',
rules: [{ direction: 'in', protocol: 'tcp', port: '443', source_ips: ['0.0.0.0/0', '::/0'] }],
})Migrating object storage between providers
ts-cloud speaks the S3 API for AWS S3, Backblaze B2 and Hetzner Object Storage, so you can move a service's data off AWS (or between any two S3-compatible buckets) with a single command. Bytes are copied (not strings), so binary payloads — images, archives, mail attachments — survive intact, and Content-Type is preserved when the source reports it. The copy is idempotent (objects already present at the destination with the same size are skipped) and can verify itself afterwards.
cloud migrate:storage \
--from aws:stacks-production-email \
--to hetzner:stacks-mail \
--include mailboxes/,inbox/,incoming/,sent/,trash/,drafts/,junk/,archive/,flags/,uids/,sms/ \
--exclude mail-server,deploy/,_deploy/,imap-server/ \
--verifyThis copies only the mail data prefixes, deliberately leaving the server
binaries and deploy artifacts behind (they show up in the report's EXCLUDED
list so you can confirm nothing was missed), then re-lists the destination and
asserts the object count and sizes match.
Flags
--from <provider:bucket>/--to <provider:bucket>— provider isaws,hetznerorbackblaze(e.g.aws:my-bucket).--from-region/--to-region,--from-endpoint/--to-endpoint— optional; default to the provider's standard endpoint.--from-prefix/--to-prefix— key prefix on each side. The source prefix is stripped and the dest prefix prepended, so you can remap (email/inbox/a.eml→mail/inbox/a.eml).--include <csv>/--exclude <csv>— only copy / skip keys under these comma-separated prefixes (excludealways wins).--dry-run— print the plan (what would copy / what is excluded) without writing.--force— re-copy even if the destination already has an object of the same size.--delete-extraneous— delete destination keys not present in the source (default OFF).--concurrency <n>— max concurrent copies (default 8).--verify— after copying, re-list the destination and assert object count + sizes match the copied set.
Credentials per provider
Set the credentials for both sides — the migrator resolves each side independently using the object-storage env conventions:
| Provider | Access key env | Secret key env | Region env |
|---|---|---|---|
aws | AWS_ACCESS_KEY_ID (or S3_ACCESS_KEY_ID) | AWS_SECRET_ACCESS_KEY (or S3_SECRET_ACCESS_KEY) | AWS_REGION |
hetzner | HETZNER_S3_ACCESS_KEY (falls back to S3_ACCESS_KEY_ID/AWS_ACCESS_KEY_ID) | HETZNER_S3_SECRET_KEY (falls back to S3_SECRET_ACCESS_KEY/AWS_SECRET_ACCESS_KEY) | HETZNER_S3_REGION |
backblaze | B2_APPLICATION_KEY_ID (falls back to S3_ACCESS_KEY_ID/AWS_ACCESS_KEY_ID) | B2_APPLICATION_KEY (falls back to S3_SECRET_ACCESS_KEY/AWS_SECRET_ACCESS_KEY) | B2_REGION |
When both sides use the same credentials (e.g. one Hetzner project), the generic
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY pair covers them.
Programmatic API
The same migration is available as a library function (usable from scripts or stacks buddy):
import { migrateObjectStorage } from 'ts-cloud'
const result = await migrateObjectStorage({
from: { provider: 'aws', bucket: 'stacks-production-email' },
to: { provider: 'hetzner', bucket: 'stacks-mail' },
include: ['mailboxes/', 'inbox/', 'sent/'],
exclude: ['mail-server', 'deploy/'],
verify: true,
})
// result: { copied, skipped, excluded, bytesCopied, errors, excludedKeys, deleted, verification }DNS Providers
ts-cloud supports multiple DNS providers for domain management and SSL certificate validation:
Cloudflare
- Log in to your Cloudflare Dashboard
- Go to My Profile→API Tokens (or visit https://dash.cloudflare.com/profile/api-tokens)
- Click Create Token
- Use the Edit zone DNS template, or create a custom token with:
- Permissions: Zone → DNS → Edit
- Zone Resources: Include → All zones (or specific zones)
- Copy the generated token
export CLOUDFLARE_API_TOKEN="your-api-token-here"Porkbun
- Log in to your Porkbun Dashboard
- Enable API access for your domain(s)
- Generate an API key pair
export PORKBUN_API_KEY="your-api-key"
export PORKBUN_SECRET_KEY="your-secret-key"GoDaddy
- Log in to GoDaddy Developer Portal
- Create a new API key
- Note both the key and secret
export GODADDY_API_KEY="your-api-key"
export GODADDY_API_SECRET="your-api-secret"
export GODADDY_ENVIRONMENT="production" # or "ote" for testingRoute53
Uses AWS credentials from environment or ~/.aws/credentials:
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_REGION="us-east-1"
export AWS_HOSTED_ZONE_ID="Z1234567890" # OptionalCLI Usage
# List domains
cloud domain:list --provider cloudflare
# List DNS records
cloud dns:records example.com --provider cloudflare
# Add a DNS record
cloud dns:add example.com A 192.168.1.1 --name api --provider cloudflare
# Generate SSL certificate with DNS validation
cloud domain:ssl example.com --provider cloudflareDevelopment
# Install dependencies
bun install
# Run tests
bun test
# Build
bun run build
# Type check
bun run typecheckArchitecture
How It Works
- Configuration - Define infrastructure in TypeScript (provider-agnostic)
- Driver Resolution -
resolveCloudProviderpicksawsorhetznerfrom your config;createCloudDriverbuilds the matching driver (cached per project) - Template / API Translation - The AWS driver generates CloudFormation templates; the Hetzner driver composes Cloud API calls + cloud-init bootstraps
- Deployment - Create/update stacks with change sets (AWS), or provision servers/firewalls/networks directly (Hetzner)
- Release Deploys - Releases upload to provider staging (S3 / on-box) and roll out over SSM or SSH with zero-downtime cutovers
- Monitoring - Track deployment progress with real-time events
No Dependencies
ts-cloud uses zero external dependencies for cloud operations:
- AWS Signature V4 - Manual request signing for authentication
- Direct HTTPS - Native
fetch()for AWS and Hetzner Cloud API calls - Credentials - Parse ~/.aws/credentials without SDK; bearer tokens for Hetzner
- CloudFormation - XML/JSON parsing for responses
This means:
- ⚡ Faster startup and execution
- 📦 Smaller bundle size
- 🔒 Better security (no supply chain attacks)
- 🎯 Full control over provider interactions
Contributing
Please see CONTRIBUTING for details.
Community
For help, discussion about best practices, or any other conversation that would benefit from being searchable:
For casual chit-chat with others using this package:
Join the Stacks Discord Server
Postcardware
"Software that is free, but hopes for a postcard." We love receiving postcards from around the world showing where Stacks is being used! We showcase them on our website too.
Our address: Stacks.js, 12665 Village Ln #2306, Playa Vista, CA 90094, United States 🌎
Sponsors
We would like to extend our thanks to the following sponsors for funding Stacks development. If you are interested in becoming a sponsor, please reach out to us.
License
The MIT License (MIT). Please see LICENSE for more information.
Made with 💙
[codecov-href]: https://codecov.io/gh/stacksjs/ts-cloud -->