@stacksjs/cloud

On this page 18

Cloud infrastructure in Stacks is declarative. You describe the resources you want in config/cloud.ts; buddy deploy reconciles the provider to that description. There is nothing to call.

No imperative service API

This page used to document Compute.deployFunction(), Storage.createBucket(), CDN.invalidate(), Database.create(), Cache, Queue, SearchEngine, AI, Network, DNS, Security, Permissions, Monitoring, FileSystem and JumpBox as importable namespaces, and Cloud.deploy() as a static method. None of those has ever existed (#2581).

Cloud is a class you construct with a config to GENERATE a CloudFormation template, and everything else is config plus the buddy cloud:* commands.

Installation

buddy add @stacksjs/cloud

Already installed in a Stacks application.

Configuration

Everything starts in config/cloud.ts.

Provider and mode

// config/cloud.ts
export default {
  project: { name: 'myapp' },

  // Where cloud state (server inventory, deploy pins) is kept.
  stateDir: 'storage/cloud',

  cloud: {
    provider: 'hetzner', // or 'aws'
  },

  // 'server'     - long-running instances (Forge-style)
  // 'serverless' - containers + static sites (Vapor-style)
  mode: 'server',
}

Environments

Each environment carries its own region, branch and variables. The branch is what makes a push deploy: a commit on deployBranch goes to that environment.

environments: {
  production: {
    type: 'production',
    deployBranch: 'main',
    region: 'us-east-1',
    variables: {
      NODE_ENV: 'production',
      LOG_LEVEL: 'info',
    },
  },

  staging: {
    type: 'staging',
    deployBranch: 'develop',
    region: 'us-east-1',
    // Prefixes every site's domain, so staging gets staging.example.com.
    domainPrefix: 'staging',
  },
},

Infrastructure

The resources themselves. Declare only what you use - the deployment shape follows from what is here, so an application with functions and no compute deploys serverless without you saying so anywhere.

infrastructure: {
  compute: {
    instances: 1,
    size: 'medium',
    disk: { size: 80, type: 'ssd', encrypted: true },

    // Firewalls are RECONCILED to this declaration on every deploy. A port
    // that is open on the box and absent here is closed by the next deploy,
    // even while the service behind it is healthy.
    firewall: {
      enabled: true,
      allowedPorts: [80, 443],
    },

    autoScaling: { min: 1, max: 5, scaleUpThreshold: 70 },
    monitoring: {
      enabled: true,
      alerts: { cpuLoadPerCore: 1.5, memPercent: 85, diskPercent: 80 },
    },
    webServer: 'rpx',
  },

  databases: { /* ... */ },
  storage: { /* ... */ },
  cdn: { /* ... */ },
  dns: { /* ... */ },
  ssl: { /* ... */ },
  loadBalancer: { /* ... */ },
  queues: { /* ... */ },
  functions: { /* ... */ },
  containers: { /* ... */ },
  monitoring: { /* ... */ },
},

The shipped config/cloud.ts documents every key inline with worked examples - mixed instance fleets, spot configuration, managed services - and is the authoritative reference for the shape.

Sites

One deployment can serve several sites. Each names a root, a domain and how to start it:

sites: {
  main: {
    root: '.',
    path: '/',
    domain: env.APP_DOMAIN || 'example.com',
    start: 'bun storage/framework/runtime/production/serve.js',
    port: 3000,
  },

  docs: {
    root: 'docs',
    path: '/docs',
    domain: 'example.com',
  },
},

A site with start and port becomes a systemd service behind the proxy; one without is served as static files.

A site's domain is a DNS contract

The gateway routes by hostname. A site whose domain has no DNS record pointing at the box does not 404 - the request reaches the gateway with a hostname it does not recognise and falls through to whichever site answers first, serving another site's content and another site's certificate.

Environment variables

# AWS
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_REGION=us-east-1

# Hetzner
HCLOUD_TOKEN=your-token

# Application
APP_NAME=myapp
APP_ENV=production
APP_DOMAIN=example.com

Deploying

buddy deploy                      # full workflow: prereqs, env, APP_KEY, DNS, mail records
buddy deploy --env staging        # a specific environment
buddy cloud:diff                  # what the next deploy would change
buddy cloud --ssh                 # shell into the server
buddy cloud:invalidate-cache      # invalidate CloudFront (--paths to scope it)

buddy deploy is the whole workflow. It checks prerequisites, resolves the environment, ensures an APP_KEY, reconciles the provider, and sets up DNS and mail records.

Managing servers

buddy cloud:add --jump-box        # add a bastion
buddy cloud:attach                # attach this project to a server another project owns
buddy cloud:move                  # move a site to another server, cutting DNS over when it serves
buddy cloud:rename                # rename a server, keeping provider, pin, hostname and inventory in step
buddy cloud:sites                 # every server and what each one hosts, across projects
buddy cloud:destroy               # destroy a drained server, once nothing on it is anyone's rollback
buddy cloud:remove                # remove the cloud entirely
buddy cloud:cleanup               # delete resources retained by a removal
buddy cloud:optimize-cost         # remove resources that can be re-applied later
buddy cloud:dashboard             # local cockpit: servers, sites, deploys

The API

The package exports far less than the configuration surface suggests, because almost everything is declarative. What it does export:

Cloud

Generates a CloudFormation template from a config. This is the seam the deploy module uses; it does not talk to a provider itself.

import { Cloud } from '@stacksjs/cloud'

const cloud = new Cloud(cloudConfig, { appEnv: 'production' })

const template = cloud.generate()      // CloudFormation JSON
const withApi = cloud.shouldDeployApi()

Deployment state

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

if (await isFirstDeployment()) {
  // Nothing exists yet - the first deploy creates rather than updates.
}

if (await isFailedState()) {
  // A previous deploy left the stack mid-update.
}

Jump box

import { addJumpBox, deleteJumpBox, getJumpBoxInstanceId } from '@stacksjs/cloud'

await addJumpBox()
const id = await getJumpBoxInstanceId()
await deleteJumpBox()

Teardown helpers

These are what cloud:remove and cloud:cleanup are built from. Each returns a Result, so a partial teardown reports which resource refused rather than throwing halfway through:

import {
  deleteCdkRemnants,
  deleteEc2Instance,
  deleteIamUsers,
  deleteLogGroups,
  deleteParameterStore,
  deleteStacksBuckets,
  deleteStacksFunctions,
  deleteSubnets,
  deleteVpcs,
} from '@stacksjs/cloud'

Domains and CDN

import { getCloudFrontDistributionId, purchaseDomain } from '@stacksjs/cloud'

await purchaseDomain('example.com', { years: 1 })
const distributionId = await getCloudFrontDistributionId()

AWS clients

The hand-rolled clients the mail commands need, re-exported because this package builds to a single bundled dist/index.js and a @stacksjs/cloud/imap/s3 subpath would resolve to a file the build never writes:

import { AWSClient, S3Client, SecretsManagerClient, SmtpServer, startSmtpServer } from '@stacksjs/cloud'

Handling Deployment Failures

buddy cloud:diff        # see what changed before retrying
buddy deploy            # retry; it is idempotent
buddy cloud:cleanup     # if a removal left resources behind

A failed deploy leaves the stack in a state isFailedState() reports. Retrying is usually right - the reconciliation is idempotent, so a deploy that failed partway resumes rather than duplicating what it already made.