stacksjs

stacks-cloud

Use when deploying or managing cloud infrastructure for Stacks - choosing between the AWS, Hetzner and SSH targets, AWS deployment via CloudFormation/CDK, server mode (EC2, ALB, VPC), serverless mode (Lambda, API Gateway, CloudFront), jump boxes, domain management (Route53), S3 storage, SES email, edge computing, security groups, IAM, the rpx gateway and systemd units on an SSH box, or the cloud configuration. Covers @stacksjs/cloud, @stacksjs/deploy, storage/framework/cloud/, and cloud/.

stacksjs 636 20 Updated 2w ago
GitHub

Install

npx skillscat add stacksjs/stacks/stacks-cloud

Install via the SkillsCat registry.

SKILL.md

Stacks Cloud & Deployment

Cloud infrastructure for Stacks, across provider-specific deploy targets, all driven through
@stacksjs/ts-cloud.

Targets

cloud.provider in config/cloud.ts decides which one runs. CLOUD_PROVIDER in the environment
overrides it. With neither set, the provider is aws.

cloud.provider Infrastructure Pipeline
'aws' (default) CloudFormation stack: EC2/ALB/VPC or Lambda/API Gateway/CloudFront Template generated and applied
'hetzner' One Hetzner Cloud server, firewall, SSH key, managed services Tarball over SSH, systemd units, rpx gateway
'ssh' A host you already own; nothing is provisioned Same tarball pipeline, after an in-place bootstrap

Read config/cloud.ts before advising anything provider-specific. This repo uses 'hetzner'.
Most of this page is the AWS target; the Hetzner and SSH sections are marked as such.

Key Paths

  • Cloud package: storage/framework/core/cloud/src/
  • Deploy package: storage/framework/core/deploy/
  • CDK stacks: storage/framework/cloud/ (deploy.ts, cdk.json, package.json)
  • Cloud config: cloud/ (serverless.ts, servers.ts, deploy-script.ts)
  • Cloud driver state: storage/cloud/ (ts-cloud's stateDir, set in config/cloud.ts)
  • Configuration: config/cloud.ts

Deployment Modes (AWS)

Server Mode (EC2)

  • EC2 instances with configurable types (t3.micro, t4g.nano, etc.)
  • Application Load Balancer (ALB)
  • VPC with public/private subnets
  • Security groups with firewall rules
  • Auto-scaling capabilities
  • SQLite database on instance
  • Docker support via Dockerfile

Serverless Mode (Lambda)

  • Lambda functions for API
  • API Gateway (REST/WebSocket)
  • CloudFront CDN for static assets
  • S3 buckets (frontend, docs, logs, assets)
  • SQS queues for background jobs
  • DynamoDB (optional)

Deployment Flow (AWS)

buddy deploy                    # deploy to cloud
  1. Validates APP_KEY format (colon-separated)
  2. Checks AWS region and credentials
  3. Validates app URL, name, team configuration
  4. Creates InfrastructureGenerator from cloud config
  5. Generates CloudFormation template
  6. Creates or updates stack with:
    • Capabilities: CAPABILITY_IAM, CAPABILITY_NAMED_IAM
    • Tags: Environment, Project, ManagedBy
    • OnFailure: ROLLBACK

CloudFront origin failover (AWS)

Off unless configured. Two shapes, both under tsCloud.infrastructure in config/cloud.ts, both
generated by ts-cloud into a CloudFront origin group (primary + secondary, failover status codes):

storage: {
  public: {
    website: true,
    // Replica of this bucket in another region, replicated with S3 replication.
    failover: { region: 'us-west-2', connectionAttempts: 1, connectionTimeout: 3 },
  },
},
cdn: {
  // A standalone distribution: any second origin, string or { domain, originPath, connection* }.
  frontend: { origin: 'a.s3.us-east-1.amazonaws.com', failoverOrigin: 'b.s3.us-west-2.amazonaws.com' },
},
  • Bucket failover needs CloudFront in front of the bucket: a website bucket, dns.domain, and SSL
    (ssl.certificateArn, or ssl.enabled + dns.hostedZoneId). Without them generation throws.
  • Default codes: bucket failover [403, 404, 500, 502, 503, 504] (OAC answers a missing object
    with 403); cdn failover [500, 502, 503, 504]. CloudFront accepts 400, 403, 404, 416, 429,
    500, 502, 503, 504 and nothing else.
  • Only GET/HEAD/OPTIONS behaviors may target the group. Compute routes (/api/*, which accept
    writes) stay on their own origin; a group behind a write-capable behavior throws at generation.
  • connectionAttempts (1-3) / connectionTimeout (1-10 s) on the primary decide how fast an
    unreachable primary fails over. CloudFront's default is 3 x 10 s.
  • The replica is created by the deploy, not the stack (a stack only makes buckets in its own
    region). Template metadata TsCloud::StorageFailover lists replicas; the deploy runs
    ensureFailoverReplicaBuckets before the stack, grantFailoverReplicaAccess and
    seedFailoverReplicas after (all from @stacksjs/ts-cloud).
  • Docs: docs/guide/cloud/origin-failover.md.

Hetzner and SSH targets

Both deploy the same way once a host exists: build each site, package a release tarball, copy it
over SSH, install and restart systemd units, preserve the prior release, and write this project's
rpx gateway fragment.

// config/cloud.ts
cloud: { provider: 'hetzner' },        // HCLOUD_TOKEN or hetzner.apiToken
cloud: { provider: 'ssh' },
ssh: {
  profile: 'raspberry-pi',             // or 'generic'
  hosts: [{ host: 'pi-stacks.local', user: 'pi', port: 22, privateKeyPath: '~/.ssh/id_ed25519', role: 'app' }],
  hostKey: 'pin',                      // 'pin' | 'accept-new' | 'insecure'
  sudo: true,
  publicIp: 'auto',                    // omit to stay LAN-only
  lan: { hostname: 'pi-stacks.local', tls: 'local-ca' },
},

Env beats config for the SSH target: TS_CLOUD_SSH_HOST, TS_CLOUD_SSH_USER,
TS_CLOUD_SSH_PORT, TS_CLOUD_SSH_KEY. The first host with no role, or role: 'app', is the
one deployed to. Multi-host fleets are not supported.

The LAN rule. An SSH host on a private address publishes no DNS, requests no Let's Encrypt
certificate, skips the CDN and skips mail reconciliation, and prints the LAN URLs instead. Private
means RFC1918, 100.64/10 CGNAT, loopback, link-local, IPv6 unique-local or link-local, a
.local / .internal / .lan / .intranet / .home.arpa name, or a bare single-label
hostname. LAN HTTPS comes from a certificate authority on the box itself (rpx localCa), not from
Let's Encrypt. To publish: routable address, ports 80 and 443 forwarded, a site with a domain, and
ssh.publicIp set. TS_CLOUD_SSH_PUBLISH_DNS=1 forces publishing on, 0 forces it off.

Shared boxes. Both targets support cloud.attachTo: '<owner slug>'. An attached project skips
provisioning and deploys only its own sites. Each project owns /etc/rpx/sites.d/<slug>.json and
replaces it wholesale, so project.slug must be unique on the box, and the config must declare
every domain that fragment currently serves or the deploy stops.

State. storage/cloud/state/<stack>.json records the host, SSH user and port, key path, host
key fingerprint, LAN address, profile and staging path. buddy cloud builds an ssh project's
fleet from the config plus those pins, since there is no provider API to enumerate; status reads
unknown because nothing polls the host. buddy server:doctor is what asks.

Cloud Helper Functions

import { getSecurityGroupId, purchaseDomain, hasBeenDeployed, isFirstDeployment, isFailedState } from '@stacksjs/cloud'

// Domain management
await purchaseDomain('example.com', { years: 1, privacy: true, autoRenew: true })

// Infrastructure queries
const sgId = await getSecurityGroupId('my-sg')
const jumpBoxId = await getJumpBoxInstanceId() // the deployed stack's JumpBoxInstanceId output
const deployed = await hasBeenDeployed()
const firstDeploy = await isFirstDeployment()
const failed = await isFailedState()

// The jump box is declared by infrastructure.jumpBox and created by `buddy deploy`;
// these report the stack's state and what to change, never create or terminate.
await addJumpBox()
await deleteJumpBox()

// Cleanup
await deleteStacksBuckets()
await deleteStacksFunctions()
await deleteLogGroups()
await deleteParameterStore()
await deleteVpcs()
await deleteCdkRemnants()
await deleteIamUsers()
await deleteSubnets()

DNS Functions (AWS Route53)

import { createHostedZone, deleteHostedZone, findHostedZone, getNameservers, updateNameservers } from '@stacksjs/cloud'

const zone = await createHostedZone('example.com')
const zoneId = await findHostedZone('example.com')
const ns = await getNameservers('example.com')
const hostedNs = await getHostedZoneNameservers('example.com')
await updateNameservers(hostedNs, 'example.com')
await deleteHostedZoneRecords('example.com')
await writeNameserversToConfig(nameservers)

Server Configuration (cloud/servers.ts)

export default [
  {
    name: 'app-server-1',
    domain: 'stacksjs.com',
    region: 'us-east-1',
    type: 'app',              // 'app' | 'web' | 'cache' | 'worker' | 'search'
    instance: 't3.micro',
    disk: 20,                  // GB
    os: 'ubuntu-20-lts-x86_64',
    bun: '1.3.0', // use the project's required/installed release
    database: { type: 'sqlite', name: 'stacks' }
  },
  // ... more servers
]

Deploy Hooks (cloud/deploy-script.ts)

export default {
  beforeDeploy({ environment, region }) {
    // Pre-deployment: build assets, validate, notify
  },
  afterDeploy({ environment, region, outputs }) {
    // Post-deployment: cache warming, smoke tests, database seeding
    console.log('Public IP:', outputs.PublicIp)
    console.log('DNS:', outputs.DNS)
  }
}

CLI Commands

buddy deploy                          # deploy application
buddy deploy:rollback [site]           # activate a preserved release (hetzner/ssh)
buddy server:doctor                   # preflight an ssh host
buddy server:setup                    # adopt and bootstrap an ssh host
buddy cloud --diff                    # show infrastructure changes (aws)
buddy cloud --ssh                     # SSH into cloud
buddy cloud --invalidate-cache        # invalidate CDN
buddy cloud:add --jump-box            # check for the jump box (declare infrastructure.jumpBox + buddy deploy)
buddy cloud:remove --force            # destroy cloud resources
buddy cloud:cleanup                   # clean retained resources
buddy cloud:optimize-cost             # remove optional resources
buddy domains:purchase <domain>       # purchase domain via Route53
buddy domains:add <domain>            # add existing domain
buddy domains:remove <domain>         # remove domain

config/cloud.ts

import type { CloudConfig } from '@stacksjs/types'
import type { CloudConfig as TsCloudConfig } from '@stacksjs/ts-cloud'

export const tsCloud: TsCloudConfig = {
  project: { name: 'my-app', slug: 'my-app', region: 'us-east-1' },
  cloud: { provider: 'hetzner' },
  mode: 'server',
}

const config: CloudConfig = { tenants: ['attached-project'] }
export default config

Extend the named tsCloud object using its typed sites, environments and
infrastructure options. The default Stacks wrapper holds surrounding settings
such as tenant isolation. Provider configuration does not belong at an invented
flat provider property. Preserve each site's gateway domains/path declarations
when changing shared-box config.

Infrastructure Stack (storage/framework/cloud/)

  • deploy.ts: Main deployment script (CDK app entry)
  • cdk.json: CDK configuration with 54 AWS context settings
  • package.json: Cloud package dependencies

Stack naming: {slugified-app-name}-cloud

Gotchas

  • Check cloud.provider first. AWS advice is wrong for a Hetzner or SSH box, and vice versa
  • On Hetzner and SSH there is no CloudFormation, no CDK, no stack, and buddy cloud --diff has
    nothing to diff
  • An SSH host is adopted, never created. buddy server:doctor checks it and buddy server:setup
    bootstraps it before the first deploy
  • A key named in ssh.hosts[].privateKeyPath that is not on disk is caught up front now; it used
    to surface much later as a bare Permission denied (publickey)
  • A Raspberry Pi has no real-time clock, so apt and ACME can both fail in the first minute after
    boot until NTP corrects it
  • AWS credentials MUST be configured (buddy configure:aws or env vars)
  • Default region is us-east-1 (from AWS_DEFAULT_REGION env)
  • APP_KEY must be colon-separated format (validated during deployment)
  • Server mode uses EC2 + ALB; serverless uses Lambda + API Gateway + CloudFront
  • Origin failover only retries GET, HEAD and OPTIONS on a cache miss; it does not protect writes,
    and the failover replica outlives buddy cloud:remove
  • Jump boxes are optional, used for SSH access to private instances. They are stack resources:
    infrastructure.jumpBox in config/cloud.ts plus buddy deploy, found by the stack's
    JumpBoxInstanceId output. The stack is stacksCloudName() (<project.name>-cloud) in
    stacksCloudRegion() (AWS_REGION, else the configured region)
  • cloud:remove with --force skips confirmation and is destructive
  • CDK toolkit stack is named stacks-toolkit
  • Environment mapping: local → development, others preserved
  • Deploy hooks run before/after deployment for custom logic
  • The cloud package has its own package.json with framework dependencies

Configuration and provider evidence

The named tsCloud export in config/cloud.ts owns the provider and
infrastructure options; the default export is Stacks' surrounding cloud config.
The short infrastructure examples above show concepts, not a substitute for the
installed TsCloudConfig type. Refer to that file and the provider's implementation
before copying a server definition, mode or storage schema.

Fly.io is another native deploy branch (Docker registry image and leased Machines),
documented in stacks-deploy. The capability matrix at
core/config/src/capabilities.ts only retains experimental Hetzner/SSH
deploy contracts. Provider availability is separate from live conformance and
rollback evidence; no registry entry should be invented to make the table look
complete. Serverless resources, realtime topology and application queue drivers
each have their own runtime contract. Provisioned infrastructure does not
automatically wire the corresponding application adapter.