# Brand the portal (/docs/deployment-ux/branding)
Branding white-labels the setup screens your customer's admin sees, so they show your product instead of Alien. The [deployment portal](/docs/deployment-ux/deployment-portal) (the setup page Alien hosts for the admin), the package downloads, and the setup CLI use your logo, colors, and domain.
Branding applies whenever the admin sets up from the portal: after you [send a link](/docs/deployment-ux/setup-links) or from a [Deploy button](/docs/deployment-ux/deploy-button). When you [build it into your product](/docs/deployment-ux/setup-screen), the setup screen is your own page, but the setup CLI name and package domain below still apply.
Branding belongs to the project, so every [setup link](/docs/deployment-ux/setup-links) for it uses it, including links for keys, models, storage, and sandboxes.
## Name, colors, and logo [#name-colors-and-logo]
Open project **Settings** → **Deployment Portal**. The editor shows the portal as you change it.
* **Brand**: avatar, **Portal Title**, **Subtitle**, and favicon.
* **Appearance**: a preset (Clean, Technical, Enterprise, Playful, Minimal), **Accent Color**, and **Density** (Comfortable or Compact).
* **Links**: **Support URL** and **Docs URL**, shown to the admin during initial setup.
Click **Save Portal**.
From the CLI, `alien projects portal set` changes only the fields you pass. The project argument defaults to the linked project:
```bash
alien projects portal set docproc \
--title Acme \
--subtitle "Document processing in your cloud" \
--preset enterprise \
--accent-color blue \
--density compact \
--avatar-url https://acme.com/logo.png \
--favicon-url https://acme.com/favicon.png \
--support-url https://acme.com/support \
--docs-url https://docs.acme.com
```
Check what's saved with `alien projects portal get docproc --json`. It prints the appearance, the domain status, and the Google sign-in settings.
Each save replaces the whole appearance on the server. Run edits one at a time: two people editing at once can overwrite each other's changes.
## Your domain [#your-domain]
By default, setup links point at Alien's host. To serve the portal and package downloads from your own domain:
1. In workspace **Settings** → **Domains**, click **Add Custom Domain**, for example `deploy.acme.com`.
2. Follow **Delegate DNS**: add the NS records at your DNS provider within 24 hours.
3. Wait for the domain to show **Verified**, then click **Use for** your project.
Once the domain is verified, you can do step 3 from the CLI. `--use` takes the domain's ID or hostname, and `--unset` goes back to the default host:
```bash
alien projects portal domain docproc --use deploy.acme.com
```
The domain then serves:
* The deployment portal: setup links become `https://deploy.acme.com/deploy#`.
* Package downloads at `pkg.deploy.acme.com`: the CLI, CloudFormation templates, Terraform modules, and Helm charts.
* Deployment URLs, as `..deploy.acme.com`.
The domain moves through **Setting up**, **Waiting for DNS**, **Active**, **Needs attention**, or **Removing**. The **Deployment Portal** page and `alien projects portal domain docproc` show it. New links use the domain once it is **Active**.
Custom domains need the Pro plan (5 domains) or Enterprise (unlimited).
Customers always get generated deployment hostnames. Only you can choose a subdomain, and only on a custom domain: set it on the link with `--subdomain`, or use `alien deploy --public-subdomain` for your own deployments. See [Networking](/docs/guide/networking).
## Google sign-in [#google-sign-in]
On Google Cloud, the admin can set up with **Google Login**. By default, Google's consent screen names Alien. To show your own name, create an OAuth web client in your Google Cloud project and add it under **Deployment Portal** → **Google Sign-In**.
From the CLI, pass the client ID and read the secret from a file, or `-` for stdin. The command prints the redirect URIs to add to the client in Google Cloud:
```bash
alien projects portal google-sign-in docproc \
--client-id 1234567890-abc.apps.googleusercontent.com \
--client-secret-file oauth-secret.txt
```
Run it without flags to see the current settings. `--alien-managed` switches back to Alien's client. See [Google Cloud](/docs/infrastructure/platforms/gcp#google-login).
## Setup CLI name [#setup-cli-name]
Admins who prefer a terminal run the setup CLI, `alien-deploy`. Rename it in project **Settings** → **Artifacts** → CLI → **Command name**, for example `acmectl`, and set its **Display name**. The portal's CLI instructions then use the new command name:
```bash
acmectl deploy --token-file setup-token.txt --name production --platform aws
```
From the CLI, send only the fields that change:
```bash
alien projects packages update --file - <<'EOF'
{ "cli": { "enabled": true, "name": "acmectl", "displayName": "Acme Deployment CLI" } }
EOF
```
Saving rebuilds the packages for the current release. The same settings rename the Helm **Chart name** and the Operator image's **Image name** and **Resource prefix**. See [Customize the packages](/docs/infrastructure/platforms/setup-packages#customize-the-packages) and [alien-deploy](/docs/reference/cli/alien-deploy).
## Settings reference [#settings-reference]
| What the admin sees | Dashboard | CLI |
| ------------------------- | ------------------------------------------------------ | -------------------------------------- |
| Name, colors, logo, links | Project **Settings** → **Deployment Portal** | `alien projects portal set` |
| Portal and package domain | Workspace **Settings** → **Domains**, then **Use for** | `alien projects portal domain` |
| Google sign-in | **Deployment Portal** → **Google Sign-In** | `alien projects portal google-sign-in` |
| Setup CLI name | Project **Settings** → **Artifacts** → CLI | `alien projects packages update` |
`alien projects portal set` flags:
| Flag | Values |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `--title` | Up to 80 characters |
| `--subtitle` | Up to 160 characters |
| `--preset` | `clean`, `technical`, `enterprise`, `playful`, `minimal` |
| `--accent-color` | `blue`, `purple`, `green`, `orange`, `pink`, `slate` |
| `--density` | `comfortable`, `compact` |
| `--avatar-url`, `--favicon-url`, `--support-url`, `--docs-url` | Absolute URLs |
| `--clear` | Resets a field to its default: `title`, `subtitle`, `avatar-url`, `favicon-url`, `support-url`, `docs-url`. Repeat for several. |
| `--file` | A JSON object of appearance fields, or `-` for stdin. Flags override the file. |
## Next [#next]
* [Deployment portal](/docs/deployment-ux/deployment-portal): the branded portal, step by step
* [Create a link](/docs/deployment-ux/setup-links): the links that open your portal
* [Follow a deployment](/docs/production/customer-setup): what you see while the admin sets up
# Add a Deploy button (/docs/deployment-ux/deploy-button)
A **Deploy** button is the **Launch from your product** option of [Deployment UX](/docs/deployment-ux): your settings page starts initial setup, the deployment portal runs it, and your page shows the result. Customers deploy on their own, without waiting for your team to send a link.
Example UI: Not deployed
```text
Document processing · Not deployed
Deploy in your cloud
Run document processing in your own AWS account, next to your files.
[Deploy] [Send to an admin]
```
**Show it when** your own database has no deployment for this organization, or its last deployment reached `deleted` or `retained`.
* **Page load.** Don't create a setup link to render this card. Create it when the customer clicks **Deploy**.
* **Lost ID.** If you have no saved deployment but a deployment may exist, look the group up by external ID before you show this card.
* **Not the admin.** The person who clicks often can't create IAM roles. **Send to an admin** creates a link to copy instead of opening it.
* **Second organization.** Members of another organization must never see this customer's link. Resolve the organization from the session.
```json title="GET /v1/deployments?project=docproc&deploymentGroup=dg_r27ict8c7vcgsumpj90ackf7b"
{
"items": [],
"nextCursor": null
}
```
The button needs the **deployment portal**: a branded setup page Alien hosts for the customer's admin. The flow:
1. The customer opens **Settings → Deploy in your cloud** in your product and clicks **Deploy**.
2. Your backend creates a [setup link](/docs/deployment-ux/setup-links), a URL that sets up a deployment for this one customer.
3. The browser opens the deployment portal, and the customer's admin runs initial setup in their own cloud.
4. Back on your page, your backend reads the deployment's progress from Alien and shows it.
The sections below follow build order, and each says where its code runs: your server, the browser, or the Alien dashboard. The example is a document-processing service in a project called `docproc`.
## Set up a client [#set-up-a-client]
This runs on your server. Create an API key in the dashboard under **Settings → API Keys**, or from the CLI:
```bash
alien api-keys create --for deployments
```
Keep the key on your server: it can create setup links and read every deployment in the project. Your frontend calls your backend, and your backend calls Alien.
TypeScript:
```ts title="lib/alien.ts"
import { Alien } from "@alienplatform/platform-api"
// Server-side only. Create the key with: alien api-keys create --for deployments
export const alien = new Alien({ apiKey: process.env.ALIEN_API_KEY, workspace: "acme" })
export const PROJECT = "docproc"
```
Python:
```python title="alien_client.py"
import os
import httpx
# Server-side only. Create the key with: alien api-keys create --for deployments
alien = httpx.Client(
base_url="https://api.alien.dev",
headers={"Authorization": f"Bearer {os.environ['ALIEN_API_KEY']}"},
timeout=30,
)
PROJECT = "docproc"
```
Rust:
```rust title="src/alien.rs"
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
pub const API: &str = "https://api.alien.dev";
pub const PROJECT: &str = "docproc";
// Server-side only. Create the key with: alien api-keys create --for deployments
pub fn client() -> reqwest::Client {
let key = std::env::var("ALIEN_API_KEY").expect("ALIEN_API_KEY is set");
let mut auth = HeaderValue::from_str(&format!("Bearer {key}")).expect("valid key");
auth.set_sensitive(true);
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, auth);
reqwest::Client::builder().default_headers(headers).build().expect("client")
}
```
TypeScript uses [`@alienplatform/platform-api`](/docs/reference/sdk/platform-api). Python and Rust call the [REST API](/docs/reference/api) directly; the examples use `httpx` and `reqwest`.
The integration uses the credentials below. Only the setup token ever reaches the customer:
| Credential | Where it lives | What it can do |
| ---------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Alien API key (`ax_…`) | Your server | Create setup links, read status, retry, resize. Removal needs a `project.admin` key: `alien api-keys create --for project-admin` |
| Setup token | In the setup link, and in the CloudFormation `Token` parameter | Set up deployments for one customer. It can't read other customers or your project |
| Gateway keys | Your server | Call the hosted gateways at runtime: `--for encryption-gateway`, `--for ai-gateway`, `--for remote-bindings`, `--for remote-sandbox` |
## Map customers to deployments [#map-customers-to-deployments]
Alien keeps one **deployment group** per customer: its record of that customer's deployments, setup links, keys, and models. You pick the group by an **external ID**, your own ID for the customer.
Use the ID of whatever owns the deployment in your product: usually the organization, sometimes a workspace or environment if one customer deploys more than once.
* **Use an immutable ID**, such as `org_8f2k`, not a name or a slug. A renamed organization must still find its deployment.
* **Resolve it from the session** on your server. Never accept it from the request body: anyone could deploy into, or read, another customer's group.
* It's case-sensitive, up to 128 characters, and matches `^[A-Za-z0-9][A-Za-z0-9._~-]*$`.
## Create the link on click [#create-the-link-on-click]
This runs on your server, in the route your **Deploy** button calls. It's one request, `POST /v1/deployment-groups/setup-links`, which finds or creates the customer's group and returns a link for it:
TypeScript:
```ts title="lib/setup-link.ts"
import { alien, PROJECT } from "./alien"
export async function createSetupLink(org: { id: string; slug: string }) {
const link = await alien.setupLinks.create({
project: PROJECT,
externalId: org.id, // from the session, never from the request
name: org.slug,
maxDeployments: 1,
setupItems: [{ item: "deployment", required: true }],
})
return { deploymentGroupId: link.deploymentGroup.id, url: link.deploymentLink }
}
```
Python:
```python title="setup_link.py"
from alien_client import PROJECT, alien
def create_setup_link(org_id: str, org_slug: str) -> dict:
r = alien.post(
"/v1/deployment-groups/setup-links",
json={
"project": PROJECT,
"externalId": org_id, # from the session, never from the request
"name": org_slug,
"maxDeployments": 1,
"setupItems": [{"item": "deployment", "required": True}],
},
)
r.raise_for_status()
link = r.json()
return {"deployment_group_id": link["deploymentGroup"]["id"], "url": link["deploymentLink"]}
```
Rust:
```rust title="src/setup_link.rs"
use serde_json::{json, Value};
use crate::alien::{client, API, PROJECT};
pub async fn create_setup_link(org_id: &str, org_slug: &str) -> reqwest::Result<(String, String)> {
let link: Value = client()
.post(format!("{API}/v1/deployment-groups/setup-links"))
.json(&json!({
"project": PROJECT,
"externalId": org_id, // from the session, never from the request
"name": org_slug,
"maxDeployments": 1,
"setupItems": [{ "item": "deployment", "required": true }],
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let group = link["deploymentGroup"]["id"].as_str().unwrap_or_default().to_owned();
let url = link["deploymentLink"].as_str().unwrap_or_default().to_owned();
Ok((group, url))
}
```
Save `deploymentGroup.id` on your organization record. Every later step reads the deployment through it.
A few things to get right here:
* **Create the link when the user clicks**, not when the settings page renders. Each call mints a new token; a page render that creates links leaves a trail of valid ones nobody used.
* **A second click is safe.** The same external ID returns the same group with a new link. The customer still ends up with one deployment, because `maxDeployments: 1` caps the group.
* **An expiry is safe.** `expiresAt` only stops new setups. Each deployment gets its own token at initial setup, so an expired or revoked link doesn't affect deployments it created. See [Link lifetime](/docs/deployment-ux/setup-links#link-lifetime).
* **Restrict choices only when you must.** Without a policy, the portal offers every platform the release supports. [Link options](/docs/deployment-ux/setup-links#options) lists `allowedPlatforms`, `allowedSetupMethods`, inputs, and defaults.
* **Give the admin a way back.** `returnUrl` adds a **Back to** link to the top of the portal, pointing at your settings page. It's a link, not a redirect, and it carries no state.
* **Recover expired links.** `refreshUrl` is where the portal sends an admin who opens an expired, revoked, or disabled link. That route must sign the user in before it creates a new link.
Both URLs must be absolute `https://` URLs of at most 2,048 characters, with no credentials in them.
The link has the form `https:///deploy#`. The token sits in the URL fragment, so browsers don't send it to servers in requests or `Referer` headers.
## Send the admin to the portal [#send-the-admin-to-the-portal]
This runs in the browser. Your route returns the link, and your page opens it in a new tab. Keep your settings page open in the first tab: that's where the customer comes back.
```ts title="components/deploy-button.tsx"
async function onDeploy() {
const res = await fetch("/api/byoc/setup-link", { method: "POST" })
const { url } = await res.json()
window.open(url, "_blank", "noopener")
setState("waiting-for-setup")
}
```
The portal is white-labeled: your logo and colors, and your domain once you add one ([Brand the portal](/docs/deployment-ux/branding)). The admin picks a platform and an **Install method**, and runs it with their own credentials. [Deployment portal](/docs/deployment-ux/deployment-portal) walks through it.
Plan for the person who clicked not being the cloud admin. Often they're a team lead who can't create IAM roles.
Next to **Deploy**, offer **Send to an admin**: it creates a link the same way and shows it to copy, with one line on what the admin will do. Anyone with the link can deploy for this customer, so show it only to signed-in members of the organization.
## When they come back [#when-they-come-back]
No state comes back to your product. The portal never redirects to your page on its own, and its **Back to** link carries no result.
From the browser, you can't tell whether the admin ran initial setup, cancelled, or is still in their cloud console. Read the state from Alien.
When your settings page loads, your server lists the group's deployments with `GET /v1/deployments?project=…&deploymentGroup=…`:
TypeScript:
```ts title="lib/find-deployment.ts"
import { alien, PROJECT } from "./alien"
const GONE = ["delete-pending", "deleting", "deleted", "retained"]
// Created when the admin starts initial setup in the portal. Null until then.
export async function findDeployment(deploymentGroupId: string) {
const { items } = await alien.deployments.list({
project: PROJECT,
deploymentGroup: deploymentGroupId,
})
return items.find((d) => !GONE.includes(d.status)) ?? null
}
```
Python:
```python title="find_deployment.py"
from alien_client import PROJECT, alien
GONE = {"delete-pending", "deleting", "deleted", "retained"}
# Created when the admin starts initial setup in the portal. None until then.
def find_deployment(deployment_group_id: str) -> dict | None:
params = {"project": PROJECT, "deploymentGroup": deployment_group_id}
r = alien.get("/v1/deployments", params=params)
r.raise_for_status()
return next((d for d in r.json()["items"] if d["status"] not in GONE), None)
```
Rust:
```rust title="src/find_deployment.rs"
use serde_json::Value;
use crate::alien::{client, API, PROJECT};
const GONE: [&str; 4] = ["delete-pending", "deleting", "deleted", "retained"];
// Created when the admin starts initial setup in the portal. None until then.
pub async fn find_deployment(deployment_group_id: &str) -> reqwest::Result> {
let page: Value = client()
.get(format!("{API}/v1/deployments"))
.query(&[("project", PROJECT), ("deploymentGroup", deployment_group_id)])
.send()
.await?
.error_for_status()?
.json()
.await?;
let items = page["items"].as_array().cloned().unwrap_or_default();
Ok(items.into_iter().find(|d| !GONE.contains(&d["status"].as_str().unwrap_or(""))))
}
```
No deployment yet means the admin hasn't started initial setup. Show **Waiting for setup**, with **Reopen setup** (a new link) and **Send to an admin**. Don't show "Deployed" because the portal tab closed.
A deployment in `pending` means the admin started initial setup, and it hasn't reported back from their cloud yet. [Waiting for setup](/docs/deployment-ux/deployment-progress#waiting-for-setup) covers that state.
If you lost the group ID, `GET /v1/deployment-groups/by-external-id?project=docproc&externalId=org_8f2k` finds it (`alien.deploymentGroups.getDeploymentGroupByExternalId` in TypeScript).
## Track the deployment [#track-the-deployment]
This runs on your server. Once a deployment exists, read it with `GET /v1/deployments/{id}` and store what you need in your own database. Your settings page reads that, never Alien directly.
Example UI: Provisioning
```text
Document processing · Setting up
Setting up in your AWS account
This usually takes 10 to 20 minutes. You can leave this page.
Resources
▸ Compute (3) Setting up
▸ Data (1) Running
▸ Secrets and keys (2) Running
▸ Networking and access (3) Running
Last update: 12 seconds ago
```
**Show it when** `status` is `provisioning`, `waiting-for-machines`, or `waiting-for-secrets`. Group `resources[]` by `type`, and show each one's `displayName`, `status`, and `progress`.
* **Polling.** Poll every 10 to 30 seconds while someone has the page open, and every few minutes from a background job otherwise.
* **API errors.** Back off, double the delay up to two minutes, and keep showing the last state you read.
* **Long setups.** A first deployment usually takes 10 to 20 minutes. Say so once, and let the customer leave the page.
* **Missing secrets.** `waiting-for-secrets` needs the customer: name the secret from `runtimeMetadata.deployerSecrets` and link to where they write it.
* **No percentages.** Resources finish in parallel. Show statuses, not a progress bar or an ETA.
```json title="GET /v1/deployments/{id}"
{
"id": "dep_0c29fq4a2yjb7kx3smwdgxlc",
"status": "provisioning",
"platform": "aws",
"resourceObservedAt": "2026-01-15T09:04:48Z",
"resources": [
{
"id": "compute",
"displayName": "Compute cluster",
"type": "compute-cluster",
"status": "provisioning",
"progress": "Waiting for machines to join the cluster.",
"error": null,
"retryAttempt": 0
}
]
}
```
[Show deployment progress](/docs/deployment-ux/deployment-progress) maps every status and the per-resource rows. [Errors and retries](/docs/deployment-ux/errors-and-retries) and [Updates and connection](/docs/deployment-ux/updates-and-connection) cover what comes after.
## Activate [#activate]
`running` means Alien finished. It doesn't prove your product can reach the service.
Run your own check from your backend, such as a request to the deployment's health endpoint, and store **Active** in your own database when it passes. Switch traffic to the deployment only then.
Read the deployment's endpoints with `GET /v1/deployments/{id}/info` (`alien.deployments.getInfo`). See [Verify and activate](/docs/deployment-ux/verify-and-activate) for the full check.
## Remove [#remove]
Give customers a **Remove** button from the first release. Removal has up to three parts:
1. A request to Alien.
2. A wait while Alien deletes what it created.
3. Sometimes, a last step the customer's admin finishes in their cloud.
See [Remove a deployment](/docs/deployment-ux/remove-a-deployment).
## Test it [#test-it]
* **Run the whole flow** with a test organization: click **Deploy**, set it up in a test account, come back to your page, and watch every state until **Active**.
* **Close the tab during initial setup** and reopen your settings page. It must show **Waiting for setup** or the deployment's progress, never a fresh empty state.
* **Click Deploy twice** and finish initial setup from the second link. Your page must show one deployment.
* **Sign in as another organization** and confirm it can't see or create links for the first.
* **Remove it** and confirm your page reaches **Removed** only after the deployment is `deleted`.
## Next [#next]
* [Show deployment progress](/docs/deployment-ux/deployment-progress): every state your page shows.
* [Who acts next](/docs/deployment-ux/who-acts-next): name who must act in every state.
* [Build your own setup screen](/docs/deployment-ux/setup-screen): replace the portal with your own page.
# Deployment portal (/docs/deployment-ux/deployment-portal)
The **deployment portal** is a white-labeled setup page Alien hosts for your customer's admin. Your customer's admin never sees Alien: they open one link and set up your software in their own cloud, on a page branded with your logo and colors, and your domain once you add one.
## What the admin gets [#what-the-admin-gets]
* **One link, no account.** They open a URL from you. They don't sign up for anything.
* **Their own cloud, their own credentials.** Initial setup runs in their AWS Console, Terraform, Helm, or terminal. You never get their cloud login.
* **The tools their team already uses.** CloudFormation, Terraform, Google sign-in, Helm, or a CLI, depending on the cloud.
* **A status page afterward.** The same link shows each deployment's status, release, and last heartbeat.
You write no code. You create the link, the admin sets up the deployment, and it appears in your dashboard.
## How it works [#how-it-works]
The admin acts once. **Initial setup** is that one-time step with their own credentials. **Ongoing management** is everything after it, which Alien runs on its own. See [Frozen and live resources](/docs/guide/frozen-and-live).
The portal serves two of the [Deployment UX options](/docs/deployment-ux): when you **send a link**, and when you **launch the portal from your product** with a [Deploy button](/docs/deployment-ux/deploy-button). To skip the portal and show your own screens, [build it into your product](/docs/deployment-ux/setup-screen).
## Create a link [#create-a-link]
A **setup link** is a URL for one customer: `https:///deploy#`. Create it in the dashboard (**Deployments** → **Deploy release**), or from the CLI:
```bash
alien onboard customer-1 --platforms aws,gcp --max-deployments 1
```
The link limits what the portal offers:
* The platforms in your stack, or fewer if you restrict them.
* The setup methods whose [setup packages](/docs/infrastructure/platforms/setup-packages) are built.
* The inputs, network and size choices, release pinning, expiry, and **Max deployments** you set.
[Create a setup link](/docs/deployment-ux/setup-links) lists every option. To create links from your backend, see [Add a Deploy button](/docs/deployment-ux/deploy-button).
To see the portal yourself, click **Open portal** after you generate a link, or open the link in a private browser window.
## Brand it [#brand-it]
Set the title, logo, colors, and support links in project **Settings** → **Deployment Portal**. By default the link points at Alien's host. Add a custom domain, such as `deploy.acme.com`, to serve the portal and the package downloads from your own domain.
[Brand the portal](/docs/deployment-ux/branding) covers each setting and the setup CLI name. [Links and branding](/docs/deployment-ux/links-and-branding) is the checklist before your first customer.
## Who opens the link [#who-opens-the-link]
The admin needs permission to create IAM identities and every [frozen resource](/docs/guide/frozen-and-live) in your stack. Frozen resources are the ones only the initial setup may create, such as a bucket that holds customer data.
In practice that's an administrator role on AWS, or Owner on the Google Cloud project or Azure subscription. On Kubernetes, they need rights to install the chart into its namespace. Each platform page lists the exact requirements.
Often the person who gets the link isn't that admin: it's your champion, a developer, or a buyer. They forward the link to the admin, and nothing else needs to happen on your side.
Anyone with the link can set up deployments for that customer and see their deployments, up to the customer's **Max deployments**. If it reaches the wrong person before a deployment exists, revoke it and send a new one. See [Manage links](/docs/deployment-ux/setup-links#manage-links).
## Open the link [#open-the-link]
* A link with one setup item opens straight on that item. A link with several opens on **Overview** (shown at the top of this page), with a card for each: **Bring your own cloud**, **Bring your own AI models**, **Bring your own key**, **Data storage**, **Container images**, or **Sandbox**. Each card shows **Connected**, **Needs attention**, or **Optional**.
* While the setup packages for a new release are still building, the portal shows **Building deployment packages**. It unlocks as soon as one setup method is ready.
* An expired or revoked link shows **Link invalid or expired** and asks the admin to get a new one from you. A link with part of the URL cut off shows **Incomplete link**.
Opening the link again, or from a second browser, shows the same customer. The portal lists the deployments already created from the link under **Deployments**, with their live status.
When the customer already holds **Max deployments**, the portal shows that list instead of a new-deployment form. A second click can't create a duplicate.
## Choose where to deploy [#choose-where-to-deploy]
The new-deployment form asks for:
* **Deployment name**: defaults to the customer's group name, for example `customer-1`.
* **Platform**: **AWS**, **Google Cloud**, **Azure**, **On-Prem**, or **Local**. A platform your stack doesn't target is disabled.
* **Install method**: the setup methods for that platform. See [Setup methods](#setup-methods).
* **Setup inputs**: the [deployer-provided inputs](/docs/guide/inputs-and-secrets) in your stack. Secrets the admin provides are written to their own secret store after initial setup, never typed into the portal.
**Configure** holds optional choices, shown only when they apply:
* **Compute**: managed compute (for example Lambda or **Managed EC2 machines**), or a Kubernetes cluster (**Amazon EKS**, **Google Kubernetes Engine**, **Azure Kubernetes Service**). On **On-Prem**: **Your machines** or **Kubernetes**.
* **Cluster source**: create a new cluster or use an existing one.
* **Network**: **Create new network**, **Use existing network** (VPC and subnet IDs), or **Default network**.
* **Who can reach this deployment's endpoints?**: **Internet** or **Only my network** (AWS). It can't change after initial setup.
* Sizes per container and machine pool, and a **Telemetry** switch when the link allows a choice.
To offer named sizes and a network choice, see [Sizes and networks](/docs/deployment-ux/sizes-and-networks).
## Setup methods [#setup-methods]
| Platform | Methods (default first) |
| ------------------------------------------------------------------------------- | ------------------------------ |
| AWS | CloudFormation, Terraform, CLI |
| Google Cloud | Google Login, Terraform, CLI |
| Azure | Terraform, CLI |
| Kubernetes on a new EKS cluster | CloudFormation, Terraform |
| Kubernetes on a new GKE or AKS cluster, or an existing EKS, GKE, or AKS cluster | Terraform |
| On-Prem → Kubernetes | Helm |
| On-Prem → Your machines | A join command |
### CloudFormation [#cloudformation]
1. **Review the stack**: the **CloudFormation template** link, and **Copy External ID** for an admin who launches the template file by hand.
2. **Choose region**: the AWS Console opens in this region.
3. **Launch in AWS**: **Launch in AWS Console** opens CloudFormation's quick-create page with every parameter filled in. The admin acknowledges that the stack creates IAM resources and creates it.
The portal names the stack `a<8 hex characters>-`, cut to 35 characters. It shows ` · ` with a **View stack in AWS** link. The deployment registers itself when the stack finishes. See [AWS](/docs/infrastructure/platforms/aws).
### Google Login [#google-login]
1. **Connect Google Cloud**: **Continue with Google**, with an account that can create service accounts, IAM bindings, APIs, and resources.
2. **Choose project**: from a list, or **Enter project ID manually**.
3. **Choose region**.
4. **Start deployment**.
Alien uses the admin's Google token for this one initial setup and then discards it. See [Google Cloud](/docs/infrastructure/platforms/gcp#google-login).
### Terraform [#terraform]
The admin clicks **Prepare Terraform**. The portal then shows **Run Terraform**:
1. **Save main.tf in an empty folder**, and replace placeholders such as ``.
2. **Set your setup token**: `export TF_VAR_token=...`, and on AWS `TF_VAR_management_external_id`.
3. **Apply in that folder**: `terraform init && terraform apply`.
The module comes from your package registry. See [Setup packages](/docs/infrastructure/platforms/setup-packages#terraform).
### CLI [#cli]
Offered on AWS, Google Cloud, and Azure:
1. **Install CLI** on a machine with access to the target account.
2. **Configure**: save the generated `deployment.toml`.
3. **Deploy to AWS** (or Google Cloud, Azure):
```bash
export ACMECTL_DEPLOYMENT_TOKEN=''
acmectl deploy --config deployment.toml
```
`acmectl` is your setup CLI's [command name](/docs/deployment-ux/branding#setup-cli-name).
### Helm [#helm]
1. **Prepare values**: save the generated `values.yaml`. It registers the deployment, and the admin reuses it for every `helm upgrade`.
2. **Install chart**: set the **Kubernetes namespace** and run the command from the target cluster's context.
See [Kubernetes](/docs/infrastructure/platforms/kubernetes).
### Your machines [#your-machines]
**Set up machines**, then **Connect machines** shows a join command to run on each Linux host. See [Linux servers](/docs/infrastructure/platforms/machines).
## While initial setup runs [#while-initial-setup-runs]
As soon as the admin starts initial setup, the portal opens the deployment's page. Until the stack or Terraform run registers, a panel says **Finish in AWS** or **Run Terraform**, with **Waiting for your stack to register**.
The admin can close the tab at any point. The deployment is already saved, and initial setup keeps running in their cloud. Opening the same link later brings them back to it:
* **Reopen AWS** reopens the CloudFormation page.
* **Check now** asks Alien to look for the registration again.
* **Cancel setup** drops an initial setup that hasn't reported back yet.
If the CloudFormation stack failed or rolled back, the panel shows **Retry AWS setup**. The admin deletes the failed stack in AWS, then starts initial setup again from the portal.
## What the initial setup creates [#what-the-initial-setup-creates]
* **Frozen resources.** The resources you mark `"frozen"` in `alien.ts`, such as the bucket or database that holds the customer's data. Only the initial setup creates, changes, or deletes them. See [Frozen and live resources](/docs/guide/frozen-and-live).
* **A management identity.** An IAM role on AWS, a service account with workload identity federation on Google Cloud, or a managed identity on Azure. It can create and update your live resources and nothing else. See [AWS](/docs/infrastructure/platforms/aws), [Google Cloud](/docs/infrastructure/platforms/gcp), [Azure](/docs/infrastructure/platforms/azure).
* **Workload identities.** One role or service account per [permission profile](/docs/guide/permissions), so your code reaches only the resources it links to.
* **The registration.** Initial setup reports the deployment to Alien, which then creates the live resources and starts the workloads.
On Kubernetes and Linux servers, initial setup deploys an agent that pulls updates instead of a management identity: the Operator in the cluster, or the machine agent on each host. See [Kubernetes](/docs/infrastructure/platforms/kubernetes) and [Linux servers](/docs/infrastructure/platforms/machines).
Before it creates anything, initial setup checks the cloud's service quotas for the stack. If one is short, it stops with the quota, its usage, and a link to request an increase. See [AWS quotas](/docs/infrastructure/platforms/aws#quotas).
## After initial setup [#after-initial-setup]
Ongoing management needs no admin. The deployment's page stays available from the same link:
* **Status**, **Platform**, **Heartbeat**, and the current release. The status moves from **Setting up** to **Provisioning** to **Running**.
* **Secrets**: where to write each secret the admin provides, with the command for their secret store. The deployment shows **Waiting for secrets** until they exist.
* **Releases**: pin or roll back, only when the link has **Allow release pinning** on.
* **Retry** after a failed initial setup, with the step to repeat for the setup method.
* **Infrastructure update needed**, when a release needs the initial setup to run again. **Continue update** shows the steps. See [Releases that need initial setup again](/docs/production/customer-setup#releases-that-need-initial-setup-again).
* **Uninstall**, and **Finish uninstall** with the last step for the setup method. See [Remove a deployment](/docs/production/customer-setup#remove-a-deployment).
When you launch the portal from your product, your product shows these states too:
* [Updates and connection](/docs/deployment-ux/updates-and-connection): releases that wait for the admin, and deployments that stop reporting.
* [Errors and retries](/docs/deployment-ux/errors-and-retries): what to show when a cloud call fails.
* [Status messages](/docs/deployment-ux/status-messages): the words for each state.
* [Remove a deployment](/docs/deployment-ux/remove-a-deployment): removal from your product.
## Next [#next]
* [Create a setup link](/docs/deployment-ux/setup-links): every option a link can set
* [Brand the portal](/docs/deployment-ux/branding): your logo, colors, and domain
* [Follow a deployment](/docs/production/customer-setup): what you see on your side, and when to step in
# Show deployment progress (/docs/deployment-ux/deployment-progress)
Deployment progress is the card in your product that tells a customer what their deployment is doing and who must act next. You show it when you launch the portal from your product with a [Deploy button](/docs/deployment-ux/deploy-button), or build it into your product with [your own setup screen](/docs/deployment-ux/setup-screen).
Example UI: Provisioning
```text
Document processing · Setting up
Setting up in your AWS account
This usually takes 10 to 20 minutes. You can leave this page.
Resources
▸ Compute (3) Setting up
▸ Data (1) Running
▸ Secrets and keys (2) Running
▸ Networking and access (3) Running
Last update: 12 seconds ago
```
Every state comes from one call, `GET /v1/deployments/{id}`, read by your backend. Errors, blocked updates, and removal have their own pages:
* [Errors and retries](/docs/deployment-ux/errors-and-retries)
* [Updates and connection](/docs/deployment-ux/updates-and-connection)
* [Follow removal](/docs/deployment-ux/follow-removal)
## Waiting for setup [#waiting-for-setup]
The initial setup runs in the customer's account, with their admin's credentials, outside your product. Until it reports back, your page waits.
Example UI: Waiting for setup
```text
Document processing · Waiting for setup
Finish the setup in AWS
The setup hasn’t started in your AWS account yet. We’ll update this page when it does.
Account: 123456789012 · us-east-1
Last checked: 8 seconds ago
[Reopen setup] [Check now] [Send to an admin]
```
**Show it when** a deployment exists, `status` is `pending` or `initial-setup`, and `lastHeartbeatAt` is `null`.
* **Console opened, nothing created.** Opening the AWS console proves nothing. Keep this state until the deployment reports, even if the customer clicks Continue.
* **Reload.** Land on the same deployment. **Reopen setup** shows the same setup step instead of creating a second deployment.
* **Check now.** It only requests a check and returns `requestedAt`. Show "Check requested" until a newer `resourceObservedAt` arrives.
* **Wrong person.** The person who clicked often isn't the cloud admin. **Send to an admin** shares the setup link, not a screenshot of your page.
* **Stack rolled back.** On CloudFormation, `setupMetadata.setupReservation.rolledBackAt` means the stack failed before it reported back. See [Come back to it](/docs/deployment-ux/setup-screen#come-back-to-it).
```json title="GET /v1/deployments/{id}"
{
"id": "dep_0c29fq4a2yjb7kx3smwdgxlc",
"status": "pending",
"setupMethod": "cloudformation",
"lastHeartbeatAt": null,
"resourceObservedAt": null,
"resources": []
}
```
Each action is one call with no body:
* **Check now**: `POST /v1/deployments/{id}/check` asks Alien to look for the deployment again, and returns `202` with `requestedAt`.
* **Reopen setup**: `POST /v1/deployments/{id}/installation` returns the same launch link, Terraform configuration or command, never a new deployment.
TypeScript:
```ts title="lib/setup-handoff.ts"
import { alien } from "./alien"
export const checkNow = (id: string) => alien.requestDeploymentCheck({ id })
export const reopenSetup = (id: string) => alien.resumeDeploymentInstallation({ id })
```
Python:
```python title="setup_handoff.py"
from alien_client import alien
def check_now(deployment_id: str) -> None:
alien.post(f"/v1/deployments/{deployment_id}/check").raise_for_status()
def reopen_setup(deployment_id: str) -> dict:
r = alien.post(f"/v1/deployments/{deployment_id}/installation")
r.raise_for_status()
return r.json()
```
Rust:
```rust title="src/setup_handoff.rs"
use serde_json::Value;
use crate::alien::{client, API};
pub async fn check_now(deployment_id: &str) -> reqwest::Result<()> {
client()
.post(format!("{API}/v1/deployments/{deployment_id}/check"))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn reopen_setup(deployment_id: &str) -> reqwest::Result {
let res = client()
.post(format!("{API}/v1/deployments/{deployment_id}/installation"))
.send()
.await?;
res.error_for_status()?.json().await
}
```
## Setting up [#setting-up]
While Alien creates the live resources, show them grouped by category, as the deployment portal and the Alien dashboard do. The example at the top of this page shows this state.
**Show it when** `status` is `provisioning`, `waiting-for-machines`, or `waiting-for-secrets`.
* **Closed categories.** Each category shows one status. Open one on its own only when it holds a failed resource.
* **No percentages.** Resources finish in parallel and take different times. Don't show a progress bar or an ETA.
* **Long setups.** A first deployment usually takes 10 to 20 minutes. Say so once, and let the customer leave the page.
* **`progress` is for people.** Show it as is; never branch on it. It's `null` when there's nothing to say.
* **Missing secrets.** `waiting-for-secrets` needs the customer. Name the secret from `runtimeMetadata.deployerSecrets`, where `status` is `missing` or `invalid`, and link to [Inputs and secrets](/docs/guide/inputs-and-secrets).
```json title="GET /v1/deployments/{id}"
{
"status": "provisioning",
"resourceObservedAt": "2026-01-15T09:04:48Z",
"resources": [
{
"id": "compute",
"displayName": "Compute cluster",
"type": "compute-cluster",
"status": "provisioning",
"progress": "Waiting for machines to join the cluster.",
"error": null,
"retryAttempt": 0
}
]
}
```
`resources` lists every resource in the deployment, including the network, identities, and cloud APIs Alien adds for the platform:
| Field | Use |
| -------------- | -------------------------------------------------------------------------------------------- |
| `displayName` | The resource's name. Falls back to `id` when the stack doesn't set one. |
| `type` | Its category. See [Categories](#categories). |
| `description` | A tooltip or a detail view, when you want one. |
| `status` | `running` is Running, `*-failed` is Failed, anything else is in progress. |
| `progress` | What the resource is waiting for right now. Show it as is. |
| `error` | The resource's last error. See [Errors and retries](/docs/deployment-ux/errors-and-retries). |
| `retryAttempt` | How many times in a row the current step has failed. |
`resourceObservedAt` says when Alien last read the resources; show it as "Last update".
Set the names in `alien.ts`, so the resources read like your product:
```ts title="alien.ts"
const api = new alien.Container("api")
.displayName("API server")
.description("Serves the document processing API.")
// ...
.build()
```
Resources Alien adds have built-in names, such as **Network** and **Management identity**. See [Supporting resources](/docs/infrastructure/supporting-resources#names-in-deployment-status).
### Categories [#categories]
Pick each resource's category from its `type`, and show the categories in this order. Leave out empty ones.
| Category | `type` |
| --------------------- | -------------------------------------------------------------------------------------------------------- |
| Compute | `worker`, `container`, `daemon`, `sandbox`, `compute-cluster`, `build` |
| Data | `storage`, `kv`, `queue`, `postgres` |
| Secrets and keys | `vault`, `key` |
| AI and email | `ai`, `email` |
| Networking and access | Every other type: networks, service accounts, the management identity, registries, cloud API activations |
Each category row shows its name, a count, and one status, worst first:
* **N failed** if any resource failed.
* **Setting up** if any is `pending`, `provisioning` or `updating`.
* **Deleting** if any is `deleting`.
* **Running** otherwise.
Inside a category, list failed resources first, then in-progress ones, then the rest in the order `resources` returns them. Each resource gets one line, with `progress` or the error message on a second line.
## Ready [#ready]
`running` with `currentReleaseId` equal to `desiredReleaseId` means Alien finished. It doesn't mean your product can reach the service yet.
Example UI: Ready
```text
Document processing · Ready to connect
Deployed. Run a connection test.
The service is running in your AWS account. Test it from your app before you switch traffic over.
Resources
▸ Compute (3) Running
▸ Data (1) Running
▸ Secrets and keys (2) Running
▸ Networking and access (3) Running
Endpoint: https://docs.customer-1.internal
Version: 12
Connection test: Not run
[Run connection test]
```
**Show it when** `status` is `running`, `currentReleaseId` equals `desiredReleaseId`, and your own connection test hasn't passed yet.
* **Not yet active.** Your product turns the feature on after its own test passes. See [Verify and activate](/docs/deployment-ux/verify-and-activate).
* **Closed categories.** Every category shows **Running** and starts closed.
* **Running an older release.** When `currentReleaseId` differs from `desiredReleaseId`, an update is pending. See [Updates and connection](/docs/deployment-ux/updates-and-connection).
```json title="GET /v1/deployments/{id}"
{
"status": "running",
"currentReleaseId": "rel_WbhQgksrawSKIpEN0NAssHX9",
"desiredReleaseId": "rel_WbhQgksrawSKIpEN0NAssHX9",
"lastHeartbeatAt": "2026-01-15T09:21:03Z",
"updateState": { "active": null, "next": null, "latest": null }
}
```
## Read the deployment [#read-the-deployment]
Poll from your backend, never from the browser: the Alien API key stays on your server. Store what you read in your own database and serve your page from it. The clients are set up in [Set up a client](/docs/deployment-ux/deploy-button#set-up-a-client).
TypeScript:
```ts title="lib/deployment-progress.ts"
import { alien } from "./alien"
export async function readDeployment(deploymentId: string) {
const d = await alien.deployments.get({ id: deploymentId })
return {
status: d.status,
resources: d.resources, // id, displayName, status, progress, error, retryAttempt
update: d.updateState.active, // a blocked or running update, or null
error: d.error,
lastHeartbeatAt: d.lastHeartbeatAt,
}
}
```
Python:
```python title="deployment_progress.py"
from alien_client import alien
def read_deployment(deployment_id: str) -> dict:
r = alien.get(f"/v1/deployments/{deployment_id}")
r.raise_for_status()
d = r.json()
return {
"status": d["status"],
"resources": d["resources"], # id, displayName, status, progress, error, retryAttempt
"update": d["updateState"]["active"], # a blocked or running update, or None
"error": d.get("error"),
"last_heartbeat_at": d.get("lastHeartbeatAt"),
}
```
Rust:
```rust title="src/deployment_progress.rs"
use serde_json::Value;
use crate::alien::{client, API};
// Fields used: status, resources, updateState.active, error, lastHeartbeatAt.
pub async fn read_deployment(deployment_id: &str) -> reqwest::Result {
let res = client().get(format!("{API}/v1/deployments/{deployment_id}")).send().await?;
res.error_for_status()?.json().await
}
```
* **Polling.** Every 10 to 30 seconds while the customer watches, and every few minutes from a background job otherwise.
* **API errors.** Back off: double the delay up to two minutes, keep showing the last state you read, and reset after a good read.
* **Stop** when the state settles: ready, failed, waiting on the customer, or removed.
* **Webhooks.** To hear about changes without polling, add a [webhook](/docs/production/webhooks) endpoint. Alien sends events such as `deployment.running` or `deployment.failed`, and your server reads the deployment then.
To find the deployment first, see [When they come back](/docs/deployment-ux/deploy-button#when-they-come-back).
## Status map [#status-map]
Your page needs a few states, not Alien's full lifecycle. Map each `status` to one:
| `status` | Product state | Headline |
| -------------------------------------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------ |
| none, `pending`, `initial-setup` | Waiting for setup | "Finish the setup in AWS." |
| `provisioning`, `waiting-for-machines`, `waiting-for-secrets` | Setting up | "Setting up in your AWS account." |
| `running` | Ready to connect | The endpoint, and your own connection test |
| `update-pending`, `updating` | Updating | "Updating to version 13." |
| `preflights-failed`, `initial-setup-failed`, `provisioning-failed`, `refresh-failed`, `update-failed`, `error` | Failed | The first failed resource's error, and **Retry** |
| `teardown-required`, `teardown-failed`, `retention-required` | Needs the customer | "Delete the setup stack to finish removal." |
| `delete-pending`, `deleting`, `delete-failed` | Removing | "Removing the service." |
| `retained` | Removed, data kept | "Removed. Your data stays in your AWS account." |
| `deleted` | Removed | Close your record |
## Test it [#test-it]
* **Unit-test your mapping** with the responses on this page and its sibling pages. Assert the headline, categories, and actions for each.
* **Deploy for real** into a test account. Watch every state, then reload during initial setup and confirm the page shows the same deployment.
* **Missing secret.** Leave a deployer secret unset, and check your page names it.
## Next [#next]
* [Errors and retries](/docs/deployment-ux/errors-and-retries): what to show when a resource fails
* [Verify and activate](/docs/deployment-ux/verify-and-activate): turn the feature on
* [Updates and connection](/docs/deployment-ux/updates-and-connection): blocked updates and stale heartbeats
* [Debug a deployment](/docs/production/debugging): when a state needs your team
# Errors and retries (/docs/deployment-ux/errors-and-retries)
Errors and retries are the states your deployment card shows when a cloud call fails. They apply when you launch the portal from your product with a [Deploy button](/docs/deployment-ux/deploy-button), and when you build it into your product with [your own setup screen](/docs/deployment-ux/setup-screen).
Example UI: Failed
```text
Document processing · Setup failed
Setup stopped
Resources
▾ Compute (3) 1 failed
Compute cluster compute Failed
You have requested more vCPU capacity than your current vCPU limit of 32 allows for the instance bucket that the specified instance type belongs to.
API server api Waiting
Not started because Compute cluster failed.
Event processor events Running
▸ Data (1) Running
▸ Secrets and keys (2) Running
▸ Networking and access (3) Running
Ask your AWS admin to raise the Running On-Demand Standard instances quota for us-east-1, then retry.
[Retry] [Contact support]
```
Show the cloud's real error, under the resource it belongs to, with the fix. "Something went wrong" sends every failure to your support queue.
## Automatic retries [#automatic-retries]
Alien retries failed cloud calls on its own. Most first failures are noise: an API that wasn't ready yet, or a permission that hadn't propagated.
While `retryAttempt` is `1`, the page looks the same as before:
Example UI: First retry
```text
Document processing · Setting up
Setting up in your AWS account
Resources
▸ Compute (3) Setting up
▸ Data (1) Running
▸ Secrets and keys (2) Running
▸ Networking and access (3) Running
```
**Show it when** a resource has an `error`, `retryAttempt` is `1`, and the resource's `status` doesn't end in `-failed`.
* **Hide the first error.** Cloud APIs fail once all the time, and the next attempt usually succeeds.
* **Keep it findable.** Put the error in a details view and in your logs, so support can see it.
* **Agents and APIs.** Hide it only in the human view. Anything your product exposes to automation should pass the error through.
```json title="GET /v1/deployments/{id}"
{
"status": "provisioning",
"resources": [
{
"id": "compute",
"displayName": "Compute cluster",
"type": "compute-cluster",
"status": "provisioning",
"error": {
"code": "CLOUD_PLATFORM_ERROR",
"message": "Could not read the security group for the machines: AWS reported it missing.",
"retryable": true
},
"retryAttempt": 1
}
]
}
```
When the same resource fails again, show the error under it and say Alien is retrying:
Example UI: Second retry
```text
Document processing · Setting up
Setting up in your AWS account
Resources
▸ Compute (3) Setting up
▸ Data (1) Running
▸ Secrets and keys (2) Running
▸ Networking and access (3) Running
```
**Show it when** the same resource still has an `error` and `retryAttempt` is `2` or more.
* **Inline, updated in place.** Show the error under its resource. A toast per poll stacks copies of the same message.
* **Say Alien is retrying.** Otherwise someone clicks **Retry** while a retry already runs.
* **Recovery.** `retryAttempt` returns to `0` after a success. Remove the message on the next poll.
* **Stopped retrying.** A status that ends in `-failed` means Alien stopped. Show the error at once, as in [Failed](#failed).
```json title="GET /v1/deployments/{id}"
{
"status": "provisioning",
"resources": [
{
"id": "compute",
"status": "provisioning",
"error": {
"code": "CLOUD_PLATFORM_ERROR",
"message": "Could not read the security group for the machines: AWS reported it missing.",
"retryable": true
},
"retryAttempt": 2
}
]
}
```
## Failed [#failed]
A failed deployment has `error` with `message` (show it), `code` (branch on it), `hint` (a suggested fix), and `retryable`. Each failed resource has its own `error` too. The example at the top of this page shows this state.
**Show it when** `status` ends in `-failed` (`preflights-failed`, `initial-setup-failed`, `provisioning-failed`, `update-failed`, `refresh-failed`) or is `error`.
* **First failure first.** List the failed resource at the top, with its error. Resources that stopped because of it come after.
* **Customer fixes.** When the fix is in the customer's account, such as a quota or a permission, say what to do before you offer **Retry**.
* **Retry requested.** After **Retry**, keep the failure on screen as "Retry requested" while `retryRequested` is `true`.
* **Not retryable.** When `error.retryable` is `false`, hide **Retry** and offer support.
* **Update failures.** `update-failed` leaves the previous release running. Say so, and don't show the service as down.
```json title="GET /v1/deployments/{id}"
{
"status": "provisioning-failed",
"retryRequested": false,
"error": { "code": "RESOURCE_FAILED", "message": "Compute cluster failed.", "retryable": true },
"resources": [
{
"id": "compute",
"displayName": "Compute cluster",
"status": "provision-failed",
"error": {
"code": "CLOUD_PLATFORM_ERROR",
"message": "You have requested more vCPU capacity than your current vCPU limit of 32 allows for the instance bucket that the specified instance type belongs to.",
"hint": "Raise the Running On-Demand Standard instances quota in us-east-1, or choose a smaller machine type.",
"retryable": true
},
"retryAttempt": 0
}
]
}
```
Retry is `POST /v1/deployments/{id}/retry`:
TypeScript:
```ts title="lib/retry.ts"
import { alien } from "./alien"
export const retryDeployment = (id: string) => alien.deployments.retry({ id })
```
Python:
```python title="retry.py"
from alien_client import alien
def retry_deployment(deployment_id: str) -> None:
alien.post(f"/v1/deployments/{deployment_id}/retry").raise_for_status()
```
Rust:
```rust title="src/retry.rs"
use crate::alien::{client, API};
pub async fn retry_deployment(deployment_id: &str) -> reqwest::Result<()> {
client()
.post(format!("{API}/v1/deployments/{deployment_id}/retry"))
.send()
.await?
.error_for_status()?;
Ok(())
}
```
Retry accepts the request and returns `202` at once. Read the deployment again to follow it.
## Error copy [#error-copy]
* **Your headline is your own sentence:** "Ask your AWS admin to raise the EC2 vCPU quota for us-east-1, then retry."
* **The cloud's message under the resource.** Show `error.message` there, and put `error.code` and the full error behind **Error details**.
* **Update in place.** A toast per poll stacks copies of the same message.
* **The first failed resource,** not the ones that stopped because of it.
* **No Retry** when `error.retryable` is `false`, or when the fix belongs to the customer and isn't done.
[Status messages](/docs/deployment-ux/status-messages) has the words to use for each state.
## Test it [#test-it]
* **Replay a retry.** Return the same `error` with `retryAttempt` 1, then 2. The main view stays quiet on 1 and shows one inline message on 2.
* **Hit a quota.** Choose a size above the test account's vCPU quota, and check your page names the quota and the admin before it offers **Retry**.
* **Retry.** Click **Retry** and confirm the page shows "Retry requested" until the status changes.
## Next [#next]
* [Show deployment progress](/docs/deployment-ux/deployment-progress): every state before ready
* [Status messages](/docs/deployment-ux/status-messages): the words to use
* [Debug a deployment](/docs/production/debugging): when your team must act
# Follow removal (/docs/deployment-ux/follow-removal)
Following removal means showing the customer where their removal stands until nothing of yours is left to manage. It applies when you launch the portal with a [Deploy button](/docs/deployment-ux/deploy-button) or build it in with [your own setup screen](/docs/deployment-ux/setup-screen), after you [start the removal](/docs/deployment-ux/remove-a-deployment).
Example UI: Waiting for the admin
```text
Document processing · Action needed
Delete the setup stack to finish
We removed the service. Your AWS admin finishes by deleting the CloudFormation stack that setup created.
aws cloudformation delete-stack --stack-name a3f9c21b0-customer-1 --region us-east-1
Stack: a3f9c21b0-customer-1
Region: us-east-1
[Copy command] [Send to an admin]
```
The split follows the two parts of a deployment's life:
* **Ongoing management**: Alien created the live resources, so Alien deletes them.
* **Initial setup**: the admin created the management identity and the [frozen resources](/docs/guide/frozen-and-live) with their own credentials, so the admin's teardown removes them, except the data you [keep](/docs/deployment-ux/remove-a-deployment#keep-or-erase-data).
## Statuses [#statuses]
Removal runs in the background. Your page shows one of these states, read from the deployment's `status`:
| `status` | Product state | Who acts next |
| ---------------------------- | -------------------------------- | --------------------- |
| `delete-pending`, `deleting` | Removing | Alien |
| `teardown-required` | Waiting for the admin | The customer's admin |
| `retention-required` | Waiting for the admin, data kept | The customer's admin |
| `delete-failed` | Removal stopped | Your team |
| `teardown-failed` | The admin's teardown failed | The customer's admin |
| `retained` | Removed, data kept | You: close the record |
| `deleted` | Removed | You: close the record |
### Removing [#removing]
While Alien deletes the live resources, your page has nothing for anyone to do. Say where the work goes now, as in the example on [Remove a deployment](/docs/deployment-ux/remove-a-deployment).
**Show it when** `status` is `delete-pending` or `deleting`.
* **Traffic first.** Switch your product off the deployment before you start removal, and say where the work goes now.
* **No Cancel.** The API can't stop a removal once it starts. Don't offer the button.
* **Who and when.** Save who started removal and when in your own database. The response doesn't return it.
* **Repeat clicks.** Calling delete again while it runs is safe: it doesn't start a second removal.
```json title="GET /v1/deployments/{id}"
{
"status": "deleting",
"platform": "aws",
"environmentInfo": { "platform": "aws", "accountId": "123456789012", "region": "us-east-1" },
"error": null,
"retryRequested": false
}
```
### Waiting for the admin [#waiting-for-the-admin]
`teardown-required` means Alien's part is done and the customer's part isn't. Give the step for the admin's setup method, from `setupMethod`:
| `setupMethod` | What the admin does |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `cloudformation` | Delete the stack: "Delete the `a3f9c21b0-customer-1` stack in CloudFormation in us-east-1." |
| `terraform` | In the Terraform folder, set `TF_VAR_token` to the deployment token from initial setup (and on AWS `TF_VAR_management_external_id`), then run `terraform destroy`. |
| `helm` | Run `helm uninstall`. |
| `cli` | Run your CLI's `destroy` command, such as `acmectl destroy --deployment-id `, with the cloud credentials used for initial setup. |
The example at the top of this page shows this state.
**Show it when** `status` is `teardown-required` or `retention-required`. `setupMethod` says which step to show.
* **Name the admin.** Alien's part is done. Say the customer's admin acts next, and give the exact step for their `setupMethod`.
* **Days, not minutes.** Keep the card on the page and remind the admin, instead of hiding it.
* **Data kept.** `retention-required` means the data resources stay. The admin still removes the setup stack; the data stays in the account.
* **The link can go.** The stack deletes itself with the deployment's own token, so revoking the setup link doesn't block this step.
```json title="GET /v1/deployments/{id}"
{
"status": "teardown-required",
"platform": "aws",
"setupMethod": "cloudformation",
"environmentInfo": { "platform": "aws", "accountId": "123456789012", "region": "us-east-1" },
"error": null
}
```
When the admin's teardown reaches Alien, the status becomes `deleted`, or `retained` when data was kept. The deployment portal shows the admin the same step under **Finish uninstall**, with the exact command for every setup method: see [Follow a deployment](/docs/production/customer-setup#remove-a-deployment).
### Failures [#failures]
`delete-failed` means Alien couldn't delete a live resource. Show `error.message`, tell the customer your team is on it, and see [Debug a deployment](/docs/production/debugging).
`teardown-failed` means the admin's teardown stopped, for example a stack deletion that failed in AWS:
Example UI: Teardown failed
```text
Document processing · Removal stopped
The setup stack didn’t delete
The service is gone, but the setup stack in your AWS account is still there.
Open the stack’s Events tab in CloudFormation to see which resource failed, fix it, then delete the stack again.
[Copy command] [Contact support]
```
**Show it when** `status` is `teardown-failed`. Show `error.message`.
* **The fix is in their account.** Tell the admin where to look: the stack's **Events** tab in CloudFormation, or the `terraform destroy` output.
* **Azure resource groups.** Terraform won't delete a resource group that still holds resources, such as a kept Key Vault. The admin deletes those first.
* **Then retry.** After the fix, the admin deletes the stack or runs `terraform destroy` again. Nothing changes on your side.
* **`delete-failed` is different.** It means Alien couldn't delete a live resource, and your team acts.
```json title="GET /v1/deployments/{id}"
{
"status": "teardown-failed",
"platform": "aws",
"setupMethod": "cloudformation",
"error": {
"code": "CLOUD_PLATFORM_ERROR",
"message": "The setup stack could not delete the management role.",
"retryable": true
}
}
```
### Removed [#removed]
`deleted` and `retained` are the end of removal. Some things stay until the customer deletes them, and they may still cost money.
Example UI: Removed
```text
Document processing · Removed
Removed from Document processing
Data that setup didn’t create, such as an existing VPC or a bucket you kept, stays in your account until you delete it.
Removed: Jan 20, 15:41
[Deploy again]
```
**Show it when** `status` is `deleted` or `retained`.
* **Say what stays.** With `retained`, list the kept data, such as the bucket with their documents. With either, an existing VPC the customer named stays too.
* **Honest wording.** Say "Removed from Document processing", not "Nothing left in your account".
* **Close your record.** Clear your active flag and endpoint, then revoke the setup link from the deployment group's **Deployment links** card.
* **Deploy again.** **Deploy again** creates a new setup link for the same customer.
```json title="GET /v1/deployments/{id}"
{
"status": "retained",
"platform": "aws",
"setupMethod": "cloudformation",
"error": null
}
```
### Map the status [#map-the-status]
**Server.** Poll the deployment and map its status:
TypeScript:
```ts title="lib/removal-state.ts"
import { alien } from "./alien"
export async function removalState(deploymentId: string) {
const d = await alien.deployments.get({ id: deploymentId })
switch (d.status) {
case "delete-pending":
case "deleting":
return { state: "removing" }
case "teardown-required":
case "retention-required":
return { state: "waiting-for-admin", setupMethod: d.setupMethod }
case "delete-failed":
case "teardown-failed":
return { state: "failed", status: d.status, message: d.error?.message }
case "deleted":
case "retained":
return { state: "removed", dataKept: d.status === "retained" }
default:
return { state: d.status }
}
}
```
Python:
```python title="removal_state.py"
from alien_client import alien
def removal_state(deployment_id: str) -> dict:
r = alien.get(f"/v1/deployments/{deployment_id}")
r.raise_for_status()
d = r.json()
match d["status"]:
case "delete-pending" | "deleting":
return {"state": "removing"}
case "teardown-required" | "retention-required":
return {"state": "waiting-for-admin", "setupMethod": d.get("setupMethod")}
case "delete-failed" | "teardown-failed":
error = d.get("error") or {}
return {"state": "failed", "status": d["status"], "message": error.get("message")}
case "deleted" | "retained":
return {"state": "removed", "dataKept": d["status"] == "retained"}
case other:
return {"state": other}
```
Rust:
```rust title="src/removal_state.rs"
use serde_json::{json, Value};
use crate::alien::{client, API};
pub async fn removal_state(deployment_id: &str) -> reqwest::Result {
let d: Value = client()
.get(format!("{API}/v1/deployments/{deployment_id}"))
.send().await?.error_for_status()?.json().await?;
let status = d["status"].as_str().unwrap_or_default();
Ok(match status {
"delete-pending" | "deleting" => json!({ "state": "removing" }),
"teardown-required" | "retention-required" => {
json!({ "state": "waiting-for-admin", "setupMethod": d["setupMethod"] })
}
"delete-failed" | "teardown-failed" => {
json!({ "state": "failed", "status": status, "message": d["error"]["message"] })
}
"deleted" | "retained" => json!({ "state": "removed", "dataKept": status == "retained" }),
other => json!({ "state": other }),
})
}
```
## Test it [#test-it]
* **Remove a test deployment end to end.** Check that your page shows **Removing**, then **Waiting for the admin** with the right command, then **Removed** only after `deleted` or `retained`.
* **Wait from the CLI** while you watch your page: `alien deployments wait customer-1/customer-1 --for deleted --timeout 1h`.
* **Delete the stack too early.** On a second deployment, delete the stack in AWS before you start removal. Alien should remove the deployment, and your page should reach **Removed**.
* **Unit-test the failures.** Feed the **Teardown failed** response to your mapping, and assert your page shows the error and names the admin.
## Next [#next]
* [Show deployment progress](/docs/deployment-ux/deployment-progress): every status and what to show
* [Status messages](/docs/deployment-ux/status-messages): the words to use
# Deployment UX (/docs/deployment-ux)
When a customer wants your software in their own cloud, someone on their side sets it up, usually a cloud admin. Deployment UX is everything that admin sees: initial setup, then the deployment's status, and removal when they leave.
1. [Send a link](/docs/deployment-ux/setup-links): Send your customer’s admin a link. They set up from your branded portal. Good for your first customers, and setups you walk through on a call.
2. [Launch from your product](/docs/deployment-ux/deploy-button): A Deploy button in your product opens the portal. Your product shows the status. Good for customers who start on their own.
3. [Build it into your product](/docs/deployment-ux/setup-screen): Your own setup screen and status. The admin never leaves your product. Good for a setup that feels fully native.
Every level sets up the same deployment. Start with a link and move up later: nothing changes in Alien.
## What the admin does [#what-the-admin-does]
The admin acts once, at the start. After that, Alien runs the deployment without them:
* **Initial setup** runs once, by the customer's admin, with their own cloud credentials. It creates the **management identity**, a scoped identity Alien uses to manage the deployment, and the [frozen resources](/docs/guide/frozen-and-live), such as the bucket that holds the customer's data.
* **Ongoing management** is everything after. Alien rolls out releases, keeps live resources current, and reports status, without the admin.
Your Deployment UX starts the initial setup, then shows what ongoing management reports. [How Alien works](/docs/guide/how-alien-works) explains the split.
## Before you start [#before-you-start]
A new deployment runs the release that the `production` channel points to. Publish one first:
```bash
alien release
```
Each release also builds the **setup packages** the admin runs: a CloudFormation template, Terraform module, Helm chart, or CLI command. Which ones depend on the platform: see [AWS](/docs/infrastructure/platforms/aws), [Google Cloud](/docs/infrastructure/platforms/gcp), [Azure](/docs/infrastructure/platforms/azure), [Kubernetes](/docs/infrastructure/platforms/kubernetes), [Linux servers](/docs/infrastructure/platforms/machines), and [Setup packages](/docs/infrastructure/platforms/setup-packages).
## Keys and AI models [#keys-and-ai-models]
Your customer's admin connects a key or a model account the same way: through a setup link or a settings page in your product. See [Customer-managed keys](/docs/guide/customer-managed-keys) and [Local AI models](/docs/guide/local-ai-models).
## Next [#next]
* [Create a setup link](/docs/deployment-ux/setup-links)
* [Add a Deploy button](/docs/deployment-ux/deploy-button)
* [Build your own setup screen](/docs/deployment-ux/setup-screen)
# Links and branding (/docs/deployment-ux/links-and-branding)
Before your first customer sets up a deployment, check the setup links you send and what their admin sees. Most checks apply when the admin uses the deployment portal: when you send a link, or launch the portal with a Deploy button. Some apply to your own setup screen too.
## Setup links [#setup-links]
A setup link only starts new setups. Each deployment gets its own token at initial setup, so expiry never breaks a running deployment. A long-lived link in someone's inbox can still set up a deployment in the wrong account.
* **Expiry.** When you send a link, set **Expires at** to a few days, and **Max deployments** to what the customer bought.
* **On click.** With a Deploy button or your own setup screen, create the link when the customer clicks, not when the page renders. Each call mints a new token.
* **Recovery.** Set `refreshUrl` so an admin with an expired link lands on your page, signs in, and gets a new one.
* **Wrong person.** Revoke a link that reached the wrong person before any deployment exists.
See [Link lifetime](/docs/deployment-ux/setup-links#link-lifetime).
## Branding [#branding]
* **Portal.** Set the title, logo, and accent color in **Settings → Deployment Portal**.
* **Domain.** Serve the portal and packages from your own domain, and wait for **Active** before you send links.
* **Setup CLI.** Rename it, for example `acmectl`, so the commands the admin runs are branded too. This applies to your own setup screen as well.
* **Google sign-in.** Add your own OAuth client, or Google's consent screen names Alien.
* **Walk through it.** Open a fresh link in a private window and set up a deployment as the admin would.
See [Brand the portal](/docs/deployment-ux/branding).
## Choices you offer [#choices-you-offer]
* **Sizes, not raw CPU.** Offer Small, Medium, and Large, and say what each includes. See [Sizes and networks](/docs/deployment-ux/sizes-and-networks).
* **Disks only grow.** Don't let a smaller size shrink the disk. See [Resize a deployment](/docs/deployment-ux/resize-a-deployment).
* **Ask where callers run** before you offer a network choice. An existing VPC needs outbound internet access from its private subnets, through a NAT gateway or equivalent.
* **Ask once about endpoint access.** It can't change after initial setup.
## Removal [#removal]
Removal has a step that belongs to the customer's admin, and it can take days. A **Removed** label shown too early leaves a stack, and cloud costs, in the customer's account.
* **Switch traffic first,** and say where the work goes now.
* **Keep data by default.** Ask in words before you send `action: "erase"`: it can't be undone.
* **No Cancel.** A started removal can't be stopped.
See [Remove a deployment](/docs/deployment-ux/remove-a-deployment).
## Test it [#test-it]
* **Open an expired link** and check it lands on your `refreshUrl`.
* **Open a link in a private window** and check every screen shows your brand and domain.
* **Remove a test deployment** and check your page reaches **Removed** only after `deleted` or `retained`.
## Next [#next]
* [Who acts next](/docs/deployment-ux/who-acts-next): name the actor in every state
* [Status messages](/docs/deployment-ux/status-messages): the words to use
* [Production go-live checklist](/docs/production/go-live-checklist): the rest of launch
# Remove a deployment (/docs/deployment-ux/remove-a-deployment)
Removing a deployment deletes your software from the customer's cloud when they turn the feature off or leave. This page is for products that launch the portal with a [Deploy button](/docs/deployment-ux/deploy-button) or build it in with [your own setup screen](/docs/deployment-ux/setup-screen). When you [send a link](/docs/deployment-ux/setup-links), your team removes deployments from the dashboard or CLI.
Example UI: Removing
```text
Document processing · Removing
Removing the service from your AWS account
Documents are processed by our hosted service again. This usually takes a few minutes.
Account: 123456789012 · us-east-1
Removal started: Jan 20, 14:02 by dana@customer-1.example
```
Your product starts removal with one API call. Alien deletes what it manages, and the customer's admin deletes what their initial setup created:
[Follow removal](/docs/deployment-ux/follow-removal) covers each state after the call.
## Switch traffic off first [#switch-traffic-off-first]
**Server.** Before you call delete, move your product off the deployment:
* Point traffic back to your hosted service.
* Stop sending work to the customer's endpoint.
* Clear your own active flag.
Once removal starts, the endpoint can disappear at any moment.
## Start removal [#start-removal]
**Server.** Removal needs a key with the `project.admin` role. Create one with `alien api-keys create --for project-admin`, and keep it apart from the key that reads status.
`POST /v1/deployments/{id}/delete` with `action: "cleanup"`. Cleanup keeps the customer's data: see [Keep or erase data](#keep-or-erase-data).
TypeScript:
```ts title="lib/remove.ts"
import { Alien } from "@alienplatform/platform-api"
// A project.admin key: alien api-keys create --for project-admin
const admin = new Alien({ apiKey: process.env.ALIEN_ADMIN_API_KEY, workspace: "acme" })
export async function removeDeployment(deploymentId: string) {
const res = await admin.deployments.delete(deploymentId, { action: "cleanup" })
return res.message
}
```
Python:
```python title="remove.py"
import os
import httpx
# A project.admin key: alien api-keys create --for project-admin
admin = httpx.Client(
base_url="https://api.alien.dev",
headers={"Authorization": f"Bearer {os.environ['ALIEN_ADMIN_API_KEY']}"},
timeout=30,
)
def remove_deployment(deployment_id: str) -> str:
r = admin.post(f"/v1/deployments/{deployment_id}/delete", json={"action": "cleanup"})
r.raise_for_status()
return r.json()["message"]
```
Rust:
```rust title="src/remove.rs"
use serde_json::{json, Value};
use crate::alien::API;
// A project.admin key: alien api-keys create --for project-admin
pub async fn remove_deployment(deployment_id: &str) -> anyhow::Result {
let key = std::env::var("ALIEN_ADMIN_API_KEY")?;
let res: Value = reqwest::Client::new()
.post(format!("{API}/v1/deployments/{deployment_id}/delete"))
.bearer_auth(key)
.json(&json!({ "action": "cleanup" }))
.send().await?.error_for_status()?.json().await?;
Ok(res["message"].as_str().unwrap_or_default().to_owned())
}
```
The call accepts the request and returns. Your page follows removal by polling, below.
Your team can also remove a deployment by hand, for example from support. In the dashboard, open the deployment under **Deployments** and delete it: the **Delete Deployment** dialog asks you to type the deployment's name. From the CLI:
```bash
alien deployments delete customer-1/customer-1
```
## Keep or erase data [#keep-or-erase-data]
Some resources hold the customer's data: buckets, databases, key-value stores, queues, vaults, and keys. By default, removal keeps them in the customer's account.
| `action` | Data resources | CLI |
| --------------------- | ------------------------------ | ------------------------------------------------------------- |
| `cleanup` or `retain` | Kept in the customer's account | `alien deployments delete customer-1/customer-1` |
| `erase` | Deleted, permanently | `alien deployments delete customer-1/customer-1 --erase-data` |
* **Ask before you erase.** Erasing can't be undone. Confirm it with the customer in your product, in words: "Delete all documents stored in your AWS account."
* **Erase later.** A removal that kept data can be turned into an erase afterwards: call delete again with `action: "erase"`.
* **One choice at a time.** While a removal runs, a request with the other choice fails with `409` `DEPLOYMENT_DELETE_POLICY_CONFLICT`. Wait for it to finish.
## Other delete actions [#other-delete-actions]
| `action` | Use |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cleanup` | The normal path above. Keeps data, like `retain`. |
| `retain` | Same as `cleanup`. |
| `erase` | Also deletes the data resources. See [Keep or erase data](#keep-or-erase-data). |
| `forget` | Delete only Alien's record, without touching the cloud. Use it for a deployment whose initial setup never ran, or whose resources are already gone. Needs a workspace admin key. From the CLI: `alien deployments delete customer-1/customer-1 --force customer-1`. |
| `detach` | Stop managing an imported pull deployment without deleting it. |
Never use `forget` on a deployment whose initial setup ran: the resources keep running in the customer's account with nothing managing them.
The cleanup call over REST, for readers without one of the languages above:
```bash
curl --fail-with-body -X POST "https://api.alien.dev/v1/deployments/$DEPLOYMENT_ID/delete" \
-H "Authorization: Bearer $ALIEN_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"action": "cleanup"}'
```
## The setup link [#the-setup-link]
The stack holds the deployment's own token, not the setup link's, so revoking the link doesn't block removal.
Once the status is `deleted`, revoke any link you no longer need from the deployment group's **Deployment links** card in the dashboard. Stacks launched with the link's own token, before deployments were created first, can still finish deletion after the link stops working; see [Link lifetime](/docs/deployment-ux/setup-links#link-lifetime).
## Test it [#test-it]
* **Remove with data.** Upload a file to the deployment's bucket, remove it with `cleanup`, and confirm the bucket is still in the account after `retained`.
* **Click twice.** Start removal twice, and confirm the second call doesn't start a second removal.
* **Erase later.** Remove with `cleanup`, then call delete with `action: "erase"`, and confirm the bucket is gone.
## Next [#next]
* [Follow removal](/docs/deployment-ux/follow-removal): the states until the deployment is gone
* [Follow a deployment](/docs/production/customer-setup#remove-a-deployment): the admin's last step for every setup method
# Resize a deployment (/docs/deployment-ux/resize-a-deployment)
Resizing changes the CPU, memory, or disk of a customer's running deployment. It applies when you build it into your product with [your own setup screen](/docs/deployment-ux/setup-screen), or launch the portal with a [Deploy button](/docs/deployment-ux/deploy-button) and show the deployment in your product.
Example UI: Done
```text
Document processing · Running
Running at Medium
Size: Medium · 2 vCPU · 4 GiB
Changed: Jan 20, 14:09
[Change size]
```
**Show it when** `updateState.active` is `null` and `updateState.latest.status` is `succeeded`, or there is no update at all.
* **Read back the size.** Show the size from your own record of the accepted request, not the label the customer clicked.
* **Custom sizes.** If the stored values match none of your sizes, say "Custom", not the nearest name.
* **Cost.** If you show cost, update the estimate now, not when the customer clicked.
* **Hidden picker.** Hide **Change size** for deployments the API can't resize. See [Resizing](#resizing).
```json title="GET /v1/deployments/{id}"
{
"status": "running",
"stackSettings": {
"compute": { "containers": { "worker": { "cpu": 2, "memory": "4Gi", "volumeSizeGib": 100 } } }
},
"updateState": {
"active": null,
"next": null,
"latest": {
"id": "duop_0vtxpb1sw4sbcdwg2xo37q6",
"status": "succeeded",
"reasons": ["configuration"],
"changedKeys": ["stackSettings.compute"],
"cause": { "kind": "configuration", "at": "2026-01-20T14:02:11Z" },
"completedAt": "2026-01-20T14:09:12Z"
}
}
}
```
## Resizing [#resizing]
While a resize runs, show the current size next to the requested one.
Example UI: Resizing
```text
Document processing · Changing
Resizing to Medium
Small keeps serving until Medium is ready. A single replica restarts to change size, so expect a short interruption.
Current size: Small · 1 vCPU · 2 GiB
Requested: Medium · 2 vCPU · 4 GiB
Requested at: 14:02 by dana@customer-1.example
```
**Show it when** `updateState.active.status` is `queued` or `applying` and `cause.kind` is `configuration`.
* **Two sizes.** Show the current size next to the requested one until the operation reports `succeeded`.
* **One change at a time.** Disable the picker while a change applies. A newer change replaces one that hasn't applied yet, and the older one reports `superseded`.
* **Short interruption.** A single-replica container restarts to change size. Say so before the customer confirms.
* **Failed resize.** `failed` isn't an outage: the old size keeps running. Show the error and the size that still runs.
* **Who asked.** `cause.actor` names who requested the change, when Alien knows it.
```json title="GET /v1/deployments/{id}/update-operations/{operationId}"
{
"id": "duop_0vtxpb1sw4sbcdwg2xo37q6",
"status": "applying",
"reasons": ["configuration"],
"changedKeys": ["stackSettings.compute"],
"targetReleaseId": "rel_WbhQgksrawSKIpEN0NAssHX9",
"blocker": null,
"actionRequired": null,
"cause": { "kind": "configuration", "at": "2026-01-20T14:02:11Z" },
"requestedAt": "2026-01-20T14:02:11Z",
"startedAt": "2026-01-20T14:02:40Z"
}
```
**Server.** Change the size, then follow the update operation it returns.
`PATCH /v1/deployments/{id}/compute`, then `GET /v1/deployments/{id}/update-operations/{operationId}`
TypeScript:
```ts title="lib/resize.ts"
import { alien } from "./alien"
import { SIZES, type Size } from "./sizes"
export async function resize(deploymentId: string, size: Size) {
const res = await alien.deployments.updateCompute(deploymentId, {
compute: { containers: { worker: { ...SIZES[size] } } },
})
if (res.outcome === "unchanged" || !res.operation) return "unchanged"
// Show "Resizing to " until the operation finishes.
const op = await alien.deployments.getUpdateOperation({
id: deploymentId,
operationId: res.operation.id,
})
return op.status // blocked | queued | applying | succeeded | failed | superseded
}
```
Python:
```python title="resize.py"
from alien_client import alien
from sizes import SIZES
def resize(deployment_id: str, size: str) -> str:
r = alien.patch(
f"/v1/deployments/{deployment_id}/compute",
json={"compute": {"containers": {"worker": SIZES[size]}}},
)
r.raise_for_status()
res = r.json()
if res["outcome"] == "unchanged" or not res.get("operation"):
return "unchanged"
# Show "Resizing to " until the operation finishes.
r = alien.get(f"/v1/deployments/{deployment_id}/update-operations/{res['operation']['id']}")
r.raise_for_status()
return r.json()["status"] # blocked | queued | applying | succeeded | failed | superseded
```
Rust:
```rust title="src/resize.rs"
use serde_json::{json, Value};
use crate::alien::{client, API};
use crate::sizes::size;
pub async fn resize(deployment_id: &str, size_name: &str) -> reqwest::Result {
let http = client();
let res: Value = http
.patch(format!("{API}/v1/deployments/{deployment_id}/compute"))
.json(&json!({ "compute": { "containers": { "worker": size(size_name) } } }))
.send().await?.error_for_status()?.json().await?;
let Some(op_id) = res["operation"]["id"].as_str() else { return Ok("unchanged".into()) };
// Show "Resizing to " until the operation finishes.
let op: Value = http
.get(format!("{API}/v1/deployments/{deployment_id}/update-operations/{op_id}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
// blocked | queued | applying | succeeded | failed | superseded
Ok(op["status"].as_str().unwrap_or_default().to_owned())
}
```
`outcome` is `accepted` or `unchanged`. Send the whole `containers` section each time.
While the change runs, the same operation shows up as `updateState.active` on the deployment. Your status poll can follow it without the operation ID.
The deployment must be on AWS, Google Cloud, or Azure and updated by push. Its initial setup must not have started yet, or it must be in one of `running`, `update-pending`, `updating`, `update-failed`, `refresh-failed`, `initial-setup`, `initial-setup-failed`, `provisioning`, `waiting-for-machines`, `waiting-for-secrets`, or `provisioning-failed`. Otherwise the call fails with `DEPLOYMENT_NOT_READY_FOR_OPERATION`.
Hide the size picker for other deployments.
### Blocked on the admin [#blocked-on-the-admin]
An operation can wait on the customer's admin, for example behind a release that needs initial setup again. It reports `blocked`, with `actionRequired` saying what to do:
Example UI: Needs the admin
```text
Document processing · Action needed
Version 13 needs your AWS admin
Version 12 keeps running at Medium. Version 13 adds a permission, so an admin must update the setup in AWS.
[Review the setup change] [Send to an admin]
```
**Show it when** `updateState.active.status` is `blocked`. `blocker.kind` is `setup` (rerun initial setup), `input` (a value is missing), or `other`.
* **Still running.** The deployment stays `running` at its current size. Read `updateState`, not only `status`.
* **Retry doesn't help.** Only the admin rerunning initial setup, or the missing input, moves it.
* **Picker disabled.** Hold further changes until the blocker clears, and say why.
```json title="GET /v1/deployments/{id}"
{
"status": "running",
"updateState": {
"active": {
"id": "duop_1x8kq2m4n6p8r0t2v4w6y8z0ab",
"status": "blocked",
"reasons": ["release"],
"blocker": { "kind": "setup", "message": "Permission profile \"processor\" was modified." },
"actionRequired": "Apply the updated setup to continue this update."
},
"next": null,
"latest": null
}
}
```
When the operation reports `succeeded`, the deployment runs at the new size, as in the example at the top of this page.
### Limits [#limits]
* Disks only grow. Refuse a smaller `volumeSizeGib` in your UI. The API refuses it too, with `DEPLOYMENT_VOLUME_SIZE_INVALID`, and saves nothing. The same error covers sizes outside the provider's range: 1 to 65,536 GiB on AWS, 10 to 65,536 GiB on Google Cloud, and 1 to 32,767 GiB on Azure.
* A resize can change machine counts, autoscaling bounds, and on AWS the machine type. Changing pools, placement, or networking needs initial setup to run again, and the call fails with `DEPLOYMENT_COMPUTE_STRUCTURE_IMMUTABLE`.
* A deployment with no stored pool choices fails with `DEPLOYMENT_COMPUTE_SETUP_REQUIRED`. Choose pools during initial setup first.
* A single-replica container restarts to change size, so expect a short interruption.
* After you grow a disk through the API, a later CloudFormation stack update that still passes the old `VolumeSizeGib` value fails. Pass the new value when the admin updates the stack.
## Test it [#test-it]
* **Resize up**, then watch your page show both sizes until `succeeded`. Expect a short interruption on a single-replica container.
* **Try to shrink the disk.** Your UI should refuse before it calls the API, and the API answers `DEPLOYMENT_VOLUME_SIZE_INVALID` if it does.
## Next [#next]
* [Sizes and networks](/docs/deployment-ux/sizes-and-networks): the sizes your product defines
* [Updates and connection](/docs/deployment-ux/updates-and-connection): blocked updates after initial setup
# Create a setup link (/docs/deployment-ux/setup-links)
A **setup link** is a URL you create for one customer. Their cloud admin opens it and sets up your software in their own cloud from the **deployment portal**, a white-labeled setup page Alien hosts on your own domain.
Two of the [Deployment UX options](/docs/deployment-ux) use setup links:
* **Send a link.** You create the link in the dashboard or CLI and send it. You write no code. This page covers it.
* **Launch from your product.** Your backend creates the link through the API when a customer clicks **Deploy**. See [Add a Deploy button](/docs/deployment-ux/deploy-button).
The dashboard calls the link the **Deployment link**, and the API returns it as `deploymentLink`. Opening it starts the initial setup; the new deployment then shows up in your dashboard.
## How a link works [#how-a-link-works]
The link has the form `https:///deploy#`. Its token can create deployments for one customer, and nothing else.
* Alien keeps one **deployment group** per customer: its record of that customer's deployments and links.
* You find the group by its **external ID**, your own ID for that customer.
* The token sits in the URL fragment, so browsers don't send it to servers in requests or `Referer` headers.
## In the dashboard [#in-the-dashboard]
1. Open **Deployments** and click **Deploy release**.
2. Choose **Deploy to your customer's environment**.
3. Enter the **Customer name**. The **External ID** defaults to a slug of the name. Click **Edit** to use your own tenant ID. It is case-sensitive.
4. Work through the steps that apply to your project:
* **Included capabilities**: what the link sets up. Shown when the project offers more than the application.
* **Customer choices**: the **Cloud destinations** (AWS, GCP, Azure, On-Prem, Local) and **AI providers** the customer may pick.
* **Customer configuration**: **Standard** or **Advanced**. See [Options](#options).
* **Required inputs**: values you provide now. The customer fills in their own inputs in the portal.
* **Advanced link settings**: setup methods, release pinning, infrastructure choices, locked environment variables, description, expiry, and max deployments.
5. Check the **Review** panel and click **Generate link**.
6. Under **Setup link ready**, copy the **Deployment link**. The token is shown only once. **Open portal** opens the link in a new tab, so you can see what the admin sees.
## From the CLI [#from-the-cli]
`alien onboard` creates the deployment group and prints the link. Without `--json`, it prompts for missing values:
```bash
alien onboard customer-1 \
--external-id org_8f2k \
--platforms aws,gcp \
--input region=eu-west-1 \
--env LOG_LEVEL=info \
--max-deployments 1 \
--json
```
```json
{
"deploymentGroupId": "dg_...",
"name": "customer-1",
"externalId": "org_8f2k",
"deploymentLink": "https://deploy.acme.com/deploy#...",
"setupItems": ["application"],
"readiness": "setup_pending",
"maxDeployments": 1,
"platforms": ["aws", "gcp"]
}
```
A link from `alien onboard` allows every setup method and never expires. To restrict methods or set an expiry, create the link in the dashboard.
Running `alien onboard` again with the same `--external-id` reuses the customer's deployment group and mints a new link.
## From your backend [#from-your-backend]
To create links from your own product, for example when a customer clicks **Deploy in your cloud** on your settings page, call `POST /v1/deployment-groups/setup-links` with a server-side API key. It takes the fields in [Options](#options). [Add a Deploy button](/docs/deployment-ux/deploy-button) walks through it.
## Send it to the admin [#send-it-to-the-admin]
Send the link to the person who can create resources in the customer's cloud account. The admin doesn't need an Alien account. A short note is enough:
> Here's the link to set up Acme in your AWS account: `https://deploy.acme.com/deploy#...` It needs permission to create IAM roles in the account. Anyone with the link can set up a deployment, so please don't share it outside your team.
[Deployment portal](/docs/deployment-ux/deployment-portal) walks through the portal from the admin's side.
## Options [#options]
| Dashboard | CLI | API field | What it does |
| -------------------------------- | --------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Customer name** | `NAME` | `name` | The deployment group's name, shown in the dashboard. |
| **External ID** | `--external-id` | `externalId` | Your stable ID for the customer. Defaults to a slug of the name. |
| **Included capabilities** | `--setup-items` | `setupItems` | What the link sets up. See [Setup items](#setup-items). |
| **Cloud destinations** | `--platforms` | `deploymentSetupConfig.policy.allowedPlatforms` | Platforms the customer may pick. Defaults to every platform in the active release. |
| **Customer configuration** | | `policy.configurationProfile` | `standard`: compute, networking, and inputs; operational settings use the package defaults. `advanced`: also update policy, health reporting, and telemetry. |
| **Required inputs** | `--input`, `--secret-input` | `inputValues` | Values for [developer-provided inputs](/docs/guide/inputs-and-secrets). |
| **Setup methods** | | `policy.allowedSetupMethods` | Setup methods the portal offers. |
| **Allow release pinning** | | `policy.allowReleasePinning` | Lets the customer pin a release on the deployment page. Off means the deployment always runs the active release. |
| **Infrastructure choices** | | `policy.stackSettings` | Network, Kubernetes cluster, update, telemetry, and heartbeat options the customer may choose. See [Sizes and networks](/docs/deployment-ux/sizes-and-networks). |
| **Locked environment variables** | `--env`, `--secret` | `deploymentSetupConfig.environmentVariables` | Injected into every deployment created from the link. |
| **Description** | | `description` | Helps you find the link later. |
| **Expires at** | | `expiresAt` | When the link stops working. Empty means no expiry. See [Link lifetime](#link-lifetime). |
| **Max deployments** | `--max-deployments` | `maxDeployments` | Deployments the group may hold. Default `100`. |
| **Metadata** | | `deploymentSetupConfig.metadata` | Your own JSON, stored on the link. |
| | | `entryPoint` | The portal step to open first, for example `{ "item": "keys", "provider": "aws" }`. Navigation only. |
| | `--subdomain` | `deploymentSetupConfig.publicSubdomain` | Reserves a public subdomain for deployments from this link. Needs a [custom domain](/docs/deployment-ux/branding#your-domain). |
The server checks every choice again when the admin runs initial setup, so restrictions don't depend on the browser.
### Setup items [#setup-items]
`setupItems` is `"all"` (every capability enabled for the project) or a list of up to 8 entries:
| Dashboard | CLI | API `item` | What the customer sets up |
| ---------------------- | ------------- | ------------ | ---------------------------------------------------------------------------------------------------------- |
| **Project deployment** | `application` | `deployment` | Your stack from the active release. Takes an optional `releaseChannel`. |
| **AI models** | `models` | `models` | Their AI provider account. See [Local AI models](/docs/guide/local-ai-models). |
| **Encryption key** | `keys` | `keys` | A key in AWS KMS, Cloud KMS, or Key Vault. See [Customer-managed keys](/docs/guide/customer-managed-keys). |
| **Bucket** | `storage` | `bucket` | A bucket or container. See [Customer storage](/docs/infrastructure/storage/customer-buckets). |
| **Container registry** | `registry` | `registry` | A registry in their cloud. |
| **Sandbox** | `sandbox` | `sandbox` | Sandboxes in their cloud. See [Remote Sandboxes](/docs/infrastructure/sandbox/customer-sandboxes). |
The CLI takes a comma-separated list, for example `--setup-items application,keys`, and defaults to `application`.
Each API entry takes `required`: initial setup isn't complete until every required item is done. Omit `setupItems` for a link that deploys your stack.
### Policy values [#policy-values]
| Field | Values |
| -------------------------------------- | ----------------------------------------------------------------------------------- |
| `allowedPlatforms` | `aws`, `gcp`, `azure`, `kubernetes`, `machines`, `local` |
| `allowedSetupMethods` | `cloudformation`, `google-oauth`, `terraform`, `helm`, `cli`, `manual` |
| `allowedKubernetesBasePlatforms` | `aws`, `gcp`, `azure`, `on-prem` |
| `allowedKubernetesClusterSources` | `create`, `existing` |
| `allowedAIProviders` | `aws-bedrock`, `gcp-vertex`, `azure-foundry`, `anthropic`, `openai`, `databricks` |
| `stackSettings.allowedNetworkModes` | `none`, `create`, `default`, `byo` |
| `stackSettings.allowedUpdatesModes` | `auto`, `approval-required` |
| `stackSettings.allowedTelemetryModes` | `auto`, `approval-required`, `off` |
| `stackSettings.allowedHeartbeatsModes` | `on`, `off` |
| `stackSettings.defaults` | Default compute sizes, network, endpoint access, updates, telemetry, and heartbeats |
Add a policy only when you must prevent a choice. Without one, the portal offers every platform the release supports and every method whose [setup package](/docs/infrastructure/platforms/setup-packages) is built.
## Manage links [#manage-links]
Each customer's links live on their deployment group page in the dashboard:
1. Open the deployment group: click **Manage links** right after you generate a link, click the group's name on one of its deployments, or pick it from the deployment groups card on **Overview**.
2. The **Deployment links** card lists each link as **Active**, **Disabled**, or **Revoked**, with its key prefix, expiry, and last use.
3. From a link's menu, **Edit policy** changes what future setups may choose and keeps the same token. **Disable** pauses the link, and **Revoke** stops it for good.
4. **Generate new link** creates another link for the same group.
From the CLI, list groups and look one up by name or external ID. To issue a new link for an existing customer, run `alien onboard` with the same `--external-id`.
```bash
alien deployment-groups list
alien deployment-groups get org_8f2k
```
Over the API, `POST /v1/deployment-groups/{id}/tokens` creates a link for an existing group, and `GET /v1/deployment-groups/by-external-id?project=docproc&externalId=org_8f2k` finds the group.
## Link lifetime [#link-lifetime]
A setup link authorizes the initial setup only. When the admin starts it, Alien creates the deployment first and gives the setup step a **deployment token** for that one deployment.
A CloudFormation stack or Terraform module keeps the deployment token, not the link's token, and uses it to register, update, and delete itself. So a link can expire or be revoked without breaking deployments it already created.
* To stop new setups, lower **Max deployments**, set **Expires at**, or revoke the link.
* If a link reaches the wrong person before any deployment exists, revoke it and send a new one.
* Older CloudFormation stacks launched with the link's own token can still be deleted after the link stops working, once Alien has removed the deployment's resources (status `teardown-required` or `deleted`). Start the removal from your side first, then let the admin delete the stack. See [Remove a deployment](/docs/production/customer-setup#remove-a-deployment).
## Next [#next]
* [Deployment portal](/docs/deployment-ux/deployment-portal): the portal, step by step, as the admin sees it
* [Brand the portal](/docs/deployment-ux/branding): white-label the portal the link opens
* [Follow a deployment](/docs/production/customer-setup): status, retries, and removal
* [Add a Deploy button](/docs/deployment-ux/deploy-button): create links from your own product
# Build your own setup screen (/docs/deployment-ux/setup-screen)
Your own setup screen is the **Build it into your product** option of [Deployment UX](/docs/deployment-ux): the customer starts the deployment from a page in your product and never sees the deployment portal. One API call creates the deployment and returns the setup step for the cloud and method the customer picked.
Example UI: Not deployed
```text
Document processing · Not deployed
Deploy in your cloud
Run document processing in your own AWS account, next to your files.
[Deploy] [Send to an admin]
```
What the customer does:
1. Opens **Deploy in your cloud** in your product, picks a cloud and a region, and clicks **Deploy**.
2. Runs the setup step your screen shows, with their own cloud credentials. On AWS that's one click into the AWS console.
3. Comes back to your screen, which follows the deployment until it's ready.
What your backend does:
1. Creates a [setup link](/docs/deployment-ux/setup-links) for the customer.
2. Creates the deployment with the link's token. The response holds the setup step.
3. Saves the deployment ID, and reads the deployment's status from then on.
## What your screen can launch [#what-your-screen-can-launch]
The response's `installation` object depends on the **setup method** (`setupMethod`) you ask for:
| Setup method | Platforms | What your screen shows |
| ---------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `cloudformation` | AWS | A button that opens `installation.launchUrl`: the AWS console's create-stack page, every parameter filled in. |
| `terraform` | AWS, Google Cloud, Azure, Kubernetes | `installation.terraform.hcl` to save as `main.tf`, and the commands that apply it. |
| `helm` | Kubernetes | `installation.helm.values` to save as `values.yaml`, and `installation.helm.command`. |
| `cli` | AWS, Google Cloud, Azure | The setup CLI: `installation.cli.installCommand`, `cli.configuration` to save as `deployment.toml`, and `cli.command`. |
Only CloudFormation opens with one click. The others are commands the admin copies and runs.
Send these customers to the [deployment portal](/docs/deployment-ux/deployment-portal) instead, from the same setup link:
* **Google Login.** Google's sign-in returns only to the portal's domain, so your own page can't run it. Offer Terraform or the CLI on your page, or send Google Cloud customers to the portal.
* **Linux servers.** The join command for your customers' machines comes from the portal.
* **Everything you don't build.** Any cloud or method your screen doesn't offer.
## Before you start [#before-you-start]
* Publish a release with `alien release`, and wait until the [setup packages](/docs/infrastructure/platforms/setup-packages) for your methods are built.
* Set up the API client from [Add a Deploy button](/docs/deployment-ux/deploy-button#set-up-a-client). The snippets import `alien` and `PROJECT` from it.
Every Alien call runs on your server. The browser only talks to your backend.
## Create the setup link [#create-the-setup-link]
**Server.** When the customer clicks **Deploy**, create a setup link for their organization. Its **setup token** can create deployments for this one customer and nothing else.
`POST /v1/deployment-groups/setup-links`
TypeScript:
```ts title="lib/setup-link.ts"
import { alien, PROJECT } from "./alien"
export async function createSetupLink(org: { id: string; slug: string }) {
const link = await alien.setupLinks.create({
project: PROJECT,
externalId: org.id, // your immutable organization ID, from the session
name: org.slug,
maxDeployments: 1,
setupItems: [{ item: "deployment", required: true }],
})
return { setupToken: link.token, portalUrl: link.deploymentLink }
}
```
Python:
```python title="setup_link.py"
from alien_client import PROJECT, alien
def create_setup_link(org_id: str, org_slug: str) -> dict:
r = alien.post("/v1/deployment-groups/setup-links", json={
"project": PROJECT,
"externalId": org_id, # your immutable organization ID, from the session
"name": org_slug,
"maxDeployments": 1,
"setupItems": [{"item": "deployment", "required": True}],
})
r.raise_for_status()
link = r.json()
return {"setup_token": link["token"], "portal_url": link["deploymentLink"]}
```
Rust:
```rust title="src/setup_link.rs"
use serde_json::{json, Value};
use crate::alien::{client, API, PROJECT};
pub async fn create_setup_link(org_id: &str, org_slug: &str) -> reqwest::Result {
let body = json!({
"project": PROJECT,
"externalId": org_id, // your immutable organization ID, from the session
"name": org_slug,
"maxDeployments": 1,
"setupItems": [{ "item": "deployment", "required": true }],
});
let req = client().post(format!("{API}/v1/deployment-groups/setup-links")).json(&body);
req.send().await?.error_for_status()?.json().await // token, deploymentLink
}
```
* **On click, not on render.** Each call mints a new token.
* **Organization from the session.** Never take it from the request body.
* **Keep `portalUrl`** for the customers you send to the portal.
[Map customers to deployments](/docs/deployment-ux/deploy-button#map-customers-to-deployments) explains the external ID. Sizes and a network choice go on this same link: see [Sizes and networks](/docs/deployment-ux/sizes-and-networks).
## Create the deployment [#create-the-deployment]
**Server.** Create the deployment with the setup token, not your API key. `setupHandoff: "await-registration"` tells Alien the customer's setup step creates the infrastructure and reports back when it's done. `installation` asks for the setup step.
`POST /v1/deployments`
TypeScript:
```ts title="lib/create-deployment.ts"
import { Alien } from "@alienplatform/platform-api"
import { PROJECT } from "./alien"
export type SetupChoice = {
platform: "aws" | "gcp" | "azure"
method: "cloudformation" | "terraform" | "cli"
region: string
}
export async function createDeployment(setupToken: string, name: string, choice: SetupChoice) {
// Acts for this one customer
const customer = new Alien({ apiKey: setupToken })
return customer.deployments.create({
name,
project: PROJECT,
platform: choice.platform,
setupItem: "deployment",
setupMethod: choice.method,
setupHandoff: "await-registration",
installation: { region: choice.region },
})
}
```
Python:
```python title="create_deployment.py"
import httpx
from alien_client import PROJECT
def create_deployment(
setup_token: str, name: str, platform: str, method: str, region: str
) -> dict:
body = {
"name": name,
"project": PROJECT,
"platform": platform,
"setupItem": "deployment",
"setupMethod": method,
"setupHandoff": "await-registration",
"installation": {"region": region},
}
r = httpx.post(
"https://api.alien.dev/v1/deployments",
headers={"Authorization": f"Bearer {setup_token}"},
json=body,
timeout=30,
)
r.raise_for_status()
# deployment, token, installation, managementExternalId
return r.json()
```
Rust:
```rust title="src/create_deployment.rs"
use serde_json::{json, Value};
use crate::alien::{API, PROJECT};
pub async fn create_deployment(
setup_token: &str,
name: &str,
platform: &str,
method: &str,
region: &str,
) -> reqwest::Result {
let body = json!({
"name": name,
"project": PROJECT,
"platform": platform,
"setupItem": "deployment",
"setupMethod": method,
"setupHandoff": "await-registration",
"installation": { "region": region },
});
// deployment, token, installation, managementExternalId
reqwest::Client::new()
.post(format!("{API}/v1/deployments"))
.bearer_auth(setup_token)
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await
}
```
The response for CloudFormation:
```json title="POST /v1/deployments"
{
"deployment": { "id": "dep_0c29fq4a2yjb7kx3smwdgxlc", "status": "pending", "setupMethod": "cloudformation" },
"token": "…",
"installation": {
"method": "cloudformation",
"region": "us-east-1",
"stackName": "a3f9c21b-customer-1",
"launchUrl": "https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/create/review?…"
}
}
```
* **`token`** is the **deployment token**: the credential for this one deployment. The setup step keeps it, so the setup link can expire without breaking the deployment.
* **Kubernetes.** Helm takes `installation.namespace`. Terraform on Kubernetes also needs `basePlatform` (`aws`, `gcp` or `azure`).
* **Package still building.** Right after a release, the call fails with `409` `INSTALLATION_NOT_READY`. Show "Preparing the setup" and try again in a minute.
## Show the setup step [#show-the-setup-step]
**Server.** Your route creates the link and the deployment, and saves the deployment ID before it returns:
```ts title="app/api/deploy/route.ts"
export async function POST(req: Request) {
const org = await currentOrg(req) // from the session, never from the body
const choice = parseSetupChoice(await req.json()) // allow only the clouds and methods you offer
const { setupToken } = await createSetupLink(org)
const created = await createDeployment(setupToken, org.slug, choice)
await db.orgs.update(org.id, { deploymentId: created.deployment.id })
const { installation, token, managementExternalId } = created
return Response.json({ installation, token, managementExternalId })
}
```
**Browser.** For CloudFormation, open the tab before you wait for your backend, so popup blockers allow it:
```ts title="components/deploy-button.ts"
export async function onDeployClick(choice: SetupChoice) {
const tab = choice.method === "cloudformation" ? window.open("about:blank", "_blank") : null
const res = await fetch("/api/deploy", { method: "POST", body: JSON.stringify(choice) })
if (!res.ok) { tab?.close(); throw new Error(await res.text()) }
const setup = await res.json()
if (tab) tab.location.href = setup.installation.launchUrl
return setup // Terraform and CLI: render the steps on your page
}
```
For Terraform, show `installation.terraform.hcl` as `main.tf`, then the commands with the response's values:
```bash title="Run Terraform"
export TF_VAR_token=''
export TF_VAR_management_external_id='' # AWS only
terraform init && terraform apply
```
For the setup CLI, show `cli.installCommand`, `cli.configuration` as `deployment.toml`, and `cli.command`. `cli.commandVariants` holds the same commands for `linux`, `mac` and `windows`.
* **The admin sees the token.** It's in the launch URL and in the commands. That's expected: it acts only on this deployment. Show it only to signed-in members of this organization.
* **Not the admin.** The person who clicks often can't create IAM roles. Offer **Send to an admin** with the setup link (`portalUrl`): the portal walks the admin through the same deployment.
## Come back to it [#come-back-to-it]
Example UI: Waiting for setup
```text
Document processing · Waiting for setup
Finish the setup in AWS
The setup hasn’t started in your AWS account yet. We’ll update this page when it does.
Account: 123456789012 · us-east-1
Last checked: 8 seconds ago
[Reopen setup] [Check now] [Send to an admin]
```
**Show it when** your saved deployment's `status` is `pending` and `lastHeartbeatAt` is `null`.
* **Reload.** Read the deployment ID you saved. **Reopen setup** calls `POST /v1/deployments/{id}/installation`, which returns the same setup step and token.
* **Second click.** Creating the deployment again with the same name and choice returns the same deployment and setup step. A CloudFormation retry keeps its stack name and release, even after you publish a new one.
* **Changed choice.** A second call with another cloud or method fails with `SETUP_RESERVATION_CONFLICT`. Show the first choice's steps, or remove the pending deployment with `action: "forget"` (see [Other delete actions](/docs/deployment-ux/remove-a-deployment#other-delete-actions)) before the customer picks again.
* **Form closed.** The admin can close the AWS tab without creating the stack. Keep this state: nothing exists in their account yet.
* **Stack building.** On CloudFormation, `setupMetadata.setupReservation.stackStartedAt` is set while AWS creates the stack. Show how long it has been building.
* **Stack rolled back.** `setupMetadata.setupReservation.rolledBackAt` is set. Ask the admin to delete the failed stack, where its **Events** tab shows why, and to start again.
```json title="GET /v1/deployments/{id}"
{
"id": "dep_0c29fq4a2yjb7kx3smwdgxlc",
"status": "pending",
"setupMethod": "cloudformation",
"lastHeartbeatAt": null,
"setupMetadata": {
"setupReservation": { "stackStartedAt": "2026-01-15T08:52:10Z", "accountId": "123456789012", "region": "us-east-1" }
}
}
```
When the setup step reports back, the deployment moves to `provisioning`. From there your screen shows the same states as every other option: see [Show deployment progress](/docs/deployment-ux/deployment-progress).
## Test it [#test-it]
* **Each method you offer.** Deploy into a test account with each one, and watch your page move from waiting to ready.
* **Cancel the AWS form.** Close the CloudFormation tab without creating the stack. Your page must stay on "Finish the setup in AWS".
* **Reload during initial setup.** Your page must show the same deployment, and **Reopen setup** the same stack name.
* **Release, then click.** Publish a release and click **Deploy** at once. Your page shows "Preparing the setup", then succeeds on a retry.
* **A Google Cloud customer.** Confirm your page sends them to the portal, or offers Terraform.
## Next [#next]
* [Show deployment progress](/docs/deployment-ux/deployment-progress): every state after initial setup starts
* [Verify and activate](/docs/deployment-ux/verify-and-activate): turn the feature on when it works
* [Sizes and networks](/docs/deployment-ux/sizes-and-networks): Small, Medium, Large and an existing VPC
* [Remove a deployment](/docs/deployment-ux/remove-a-deployment): when the customer leaves
# Sizes and networks (/docs/deployment-ux/sizes-and-networks)
Sizes and networks are the capacity and network choices for a customer's deployment: how much CPU, memory, and disk it gets, and which VPC it runs in. With [your own setup screen](/docs/deployment-ux/setup-screen) (**Build it into your product**), your product offers them. When the admin sets up from the deployment portal, its **Configure** step does.
To offer them in your product:
1. Declare CPU and memory ranges in `alien.ts`.
2. Map each size to CPU, memory, and disk, and put the choice on the setup link.
3. Put the network choice on the same link.
## Declare ranges [#declare-ranges]
A container accepts per-deployment sizes only inside ranges you declare in `alien.ts`. Give `cpu` and `memory` a `min`, `max`, and `default`:
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const worker = new alien.Container("worker")
.code({ type: "image", image: "ghcr.io/acme/docproc-worker:1.8.0" })
.cpu({ min: 1, max: 4, default: 1 })
.memory({ min: "2Gi", max: "8Gi", default: "2Gi" })
.persistentStorage("50Gi")
.replicas(1)
.port(8080)
.build()
export default new alien.Stack("docproc").add(worker, "live").build()
```
Release this with `alien release`. Setup links and resizes can then choose values inside the ranges. See [Compute](/docs/infrastructure/compute).
## Map sizes to settings [#map-sizes-to-settings]
**Server.** Define your sizes once, keyed by the container ID from `alien.ts`, and put the chosen one in the link's `deploymentSetupConfig.policy.stackSettings.defaults.compute`.
`POST /v1/deployment-groups/setup-links`
TypeScript:
```ts title="lib/sizes.ts"
import { alien, PROJECT } from "./alien"
// Your product's sizes, mapped to the container ID "worker" in alien.ts.
export const SIZES = {
small: { cpu: 1, memory: "2Gi", volumeSizeGib: 50 },
medium: { cpu: 2, memory: "4Gi", volumeSizeGib: 100 },
large: { cpu: 4, memory: "8Gi", volumeSizeGib: 200 },
} as const
export type Size = keyof typeof SIZES
export async function createSizedSetupLink(org: { id: string; slug: string }, size: Size) {
return alien.setupLinks.create({
project: PROJECT,
externalId: org.id,
name: org.slug,
setupItems: [{ item: "deployment", required: true }],
deploymentSetupConfig: {
policy: {
allowedPlatforms: ["aws"],
stackSettings: {
defaults: {
compute: { containers: { worker: { ...SIZES[size] } } },
network: { type: "create" },
endpointAccess: "internet",
},
},
},
},
})
}
```
Python:
```python title="sizes.py"
from alien_client import alien, PROJECT
# Your product's sizes, mapped to the container ID "worker" in alien.ts.
SIZES = {
"small": {"cpu": 1, "memory": "2Gi", "volumeSizeGib": 50},
"medium": {"cpu": 2, "memory": "4Gi", "volumeSizeGib": 100},
"large": {"cpu": 4, "memory": "8Gi", "volumeSizeGib": 200},
}
def create_sized_setup_link(org_id: str, org_slug: str, size: str) -> dict:
defaults = {
"compute": {"containers": {"worker": SIZES[size]}},
"network": {"type": "create"},
"endpointAccess": "internet",
}
r = alien.post("/v1/deployment-groups/setup-links", json={
"project": PROJECT,
"externalId": org_id,
"name": org_slug,
"setupItems": [{"item": "deployment", "required": True}],
"deploymentSetupConfig": {
"policy": {"allowedPlatforms": ["aws"], "stackSettings": {"defaults": defaults}},
},
})
r.raise_for_status()
return r.json()
```
Rust:
```rust title="src/sizes.rs"
use serde_json::{json, Value};
use crate::alien::{client, API, PROJECT};
// Your product's sizes, mapped to the container ID "worker" in alien.ts.
pub fn size(name: &str) -> Value {
match name {
"large" => json!({ "cpu": 4, "memory": "8Gi", "volumeSizeGib": 200 }),
"medium" => json!({ "cpu": 2, "memory": "4Gi", "volumeSizeGib": 100 }),
_ => json!({ "cpu": 1, "memory": "2Gi", "volumeSizeGib": 50 }),
}
}
pub async fn create_sized_setup_link(
org_id: &str,
org_slug: &str,
size_name: &str,
) -> reqwest::Result {
let defaults = json!({
"compute": { "containers": { "worker": size(size_name) } },
"network": { "type": "create" },
"endpointAccess": "internet",
});
client()
.post(format!("{API}/v1/deployment-groups/setup-links"))
.json(&json!({
"project": PROJECT,
"externalId": org_id,
"name": org_slug,
"setupItems": [{ "item": "deployment", "required": true }],
"deploymentSetupConfig": {
"policy": {
"allowedPlatforms": ["aws"],
"stackSettings": { "defaults": defaults },
},
},
}))
.send()
.await?
.error_for_status()?
.json()
.await
}
```
Alien picks machines that fit the chosen size. For pools that run several containers, set a machine type and count under `compute.pools`.
**Your own setup screen.** A deployment you create with this link's token gets the same defaults, and the setup step Alien returns carries them. See [Build your own setup screen](/docs/deployment-ux/setup-screen).
### Show sizes, not raw CPU [#show-sizes-not-raw-cpu]
Customers know their workload, not your container's resource needs. Raw CPU and memory fields invite settings that don't fit.
* **Say what each size includes:** "Medium: 2 vCPU, 4 GiB, 100 GiB disk."
* **Show cost as an estimate.** Alien doesn't return prices. Compute it from the machine and disk sizes: "Approx. $120/month in cloud costs, billed by AWS".
* **No number.** Show "Estimate unavailable" rather than nothing, so customers don't read it as free.
## Choose the network [#choose-the-network]
`stackSettings.defaults.network` decides where the deployment runs on AWS:
| `network.type` | What initial setup does | CloudFormation `NetworkMode` |
| -------------- | --------------------------------------------------------------------------------------------- | ---------------------------- |
| `create` | Creates a new VPC with public and private subnets. Optional CIDR and availability zone count. | `create-new` |
| `use-default` | Uses the account's default VPC. | `use-default` |
| `byo-vpc-aws` | Uses an existing VPC and subnets the customer names. | `use-existing` |
`endpointAccess` decides who can reach public endpoints: `internet` (default) or `private` (reachable only inside the network). It's fixed at initial setup.
A customer whose callers run inside their own VPC usually wants the existing VPC and a private endpoint. Pass these defaults on the link, in place of the ones above:
TypeScript:
```ts title="lib/private-network.ts"
// stackSettings.defaults for an existing VPC and a private endpoint.
export const privateNetworkDefaults = {
network: {
type: "byo-vpc-aws",
vpcId: "vpc-0a1b2c3d4e5f67890",
privateSubnetIds: ["subnet-0aaa1111bbbb2222c", "subnet-0ddd3333eeee4444f"],
publicSubnetIds: ["subnet-0123456789abcdef0", "subnet-0fedcba9876543210"],
},
endpointAccess: "private",
} as const
```
Python:
```python title="private_network.py"
# stackSettings.defaults for an existing VPC and a private endpoint.
# Over REST, the network fields are snake_case.
PRIVATE_NETWORK_DEFAULTS = {
"network": {
"type": "byo-vpc-aws",
"vpc_id": "vpc-0a1b2c3d4e5f67890",
"private_subnet_ids": ["subnet-0aaa1111bbbb2222c", "subnet-0ddd3333eeee4444f"],
"public_subnet_ids": ["subnet-0123456789abcdef0", "subnet-0fedcba9876543210"],
},
"endpointAccess": "private",
}
```
Rust:
```rust title="src/private_network.rs"
use serde_json::{json, Value};
// stackSettings.defaults for an existing VPC and a private endpoint.
// Over REST, the network fields are snake_case.
pub fn private_network_defaults() -> Value {
json!({
"network": {
"type": "byo-vpc-aws",
"vpc_id": "vpc-0a1b2c3d4e5f67890",
"private_subnet_ids": ["subnet-0aaa1111bbbb2222c", "subnet-0ddd3333eeee4444f"],
"public_subnet_ids": ["subnet-0123456789abcdef0", "subnet-0fedcba9876543210"],
},
"endpointAccess": "private",
})
}
```
The TypeScript client writes `vpcId` and sends `vpc_id`. Over REST, use the snake_case names: `vpc_id`, `private_subnet_ids`, `public_subnet_ids`, and for `create`, `cidr` and `availability_zones`.
* **Outbound access.** An existing VPC needs outbound internet access from its private subnets (a NAT gateway or equivalent), so the deployment can pull images and reach Alien. Say so next to the VPC picker, before initial setup fails on it.
* **Ask where the callers run.** A new VPC isolates the deployment but can put it far from the systems that call it. Offer an existing VPC when callers run inside the customer's network.
* **Ask once.** Endpoint access can't change after initial setup.
See [Networking](/docs/guide/networking) and [AWS](/docs/infrastructure/platforms/aws).
## Test it [#test-it]
* **Set up at each size** in a test account, and check that the deployment's `stackSettings.compute` holds the CPU, memory and disk you chose.
* **Try an existing VPC without NAT** and confirm your page shows the initial setup failure and names the admin as the one who fixes it.
## Next [#next]
* [Build your own setup screen](/docs/deployment-ux/setup-screen): the page these settings plug into
* [Resize a deployment](/docs/deployment-ux/resize-a-deployment): change the size after initial setup
# Status messages (/docs/deployment-ux/status-messages)
Status messages are the headlines and errors your deployment card shows the customer. These practices apply to every [Deployment UX option](/docs/deployment-ux) that shows status in your product: a Deploy button and your own setup screen.
Example UI: Second retry
```text
Document processing · Setting up
Setting up in your AWS account
Resources
▸ Compute (3) Setting up
▸ Data (1) Running
▸ Secrets and keys (2) Running
▸ Networking and access (3) Running
```
Name the cloud and the customer's role ("your AWS admin"), not Alien. Your customer bought your product.
## Words to use [#words-to-use]
| Situation | Use | Avoid |
| ------------------------- | ------------------------------------------------------------ | ------------------------------ |
| Waiting for initial setup | "Finish the setup in AWS." | "Deploying…" |
| Setting up | "Setting up in your AWS account." | "45% complete" |
| Repeated retry | "Could not read the security group. Retrying automatically." | "Failed" |
| Failed, customer fix | "Ask your AWS admin to raise the vCPU quota for us-east-1." | "Something went wrong." |
| Running, not tested | "Deployed. Run a connection test." | "Live" |
| Stale heartbeat | "We can't confirm the current state." | "Down", "Offline" |
| Blocked update | "Version 13 needs your AWS admin. Version 12 keeps running." | "Update failed" |
| Removed | "Removed from Document processing." | "Nothing left in your account" |
Say "set up" or "deploy" for what the admin does. Never show internal steps or IDs in the headline.
## Errors [#errors]
Show the cloud's real error, under the resource it belongs to, with the fix.
* **Hide the first retry.** `retryAttempt` is `1`: nothing in the main view. Keep it in a details view and your logs.
* **Show repeated retries inline.** `retryAttempt` is `2` or more: the error under its resource, with "Retrying automatically."
* **Show a stop at once.** A status that ends in `-failed`: the error, with what to do.
* **Pass every error to automation.** Hide it only for humans. APIs and agents get every error from the first failure.
[Errors and retries](/docs/deployment-ux/errors-and-retries) has the fields and the responses.
## Accessibility [#accessibility]
* **Text, not color alone.** Every badge and dot has a word next to it.
* **Announce changes once.** Put the status headline in an `aria-live="polite"` region, and update it only when the state changes, not on each poll.
* **Keep focus.** Don't move focus when a poll updates the page.
* **Readable times.** Render relative times in a `` element with the full time on hover.
* **Reduced motion.** Swap spinners for a static icon when `prefers-reduced-motion` is set.
## Test it [#test-it]
* **Read every headline aloud** with a screen reader while the page polls. It announces each state change once.
* **Replay a retry.** Return the same `error` with `retryAttempt` 1, then 2. The main view stays quiet on 1 and shows one inline message on 2.
## Next [#next]
* [Who acts next](/docs/deployment-ux/who-acts-next): name the actor in every state
* [Links and branding](/docs/deployment-ux/links-and-branding): checks before your first customer
# Updates and connection (/docs/deployment-ux/updates-and-connection)
After initial setup, your deployment card stays on the page for the life of the deployment. Two states need care: a release that waits for the customer's admin, and a deployment that stopped reporting. Both apply when you launch the portal from your product with a [Deploy button](/docs/deployment-ux/deploy-button), and when you build it into your product with [your own setup screen](/docs/deployment-ux/setup-screen).
Example UI: Update needs the admin
```text
Document processing · Action needed
Version 13 needs your AWS admin
Version 12 keeps running. Version 13 adds a permission, so an admin must update the setup in AWS.
[Review the setup change] [Send to an admin]
```
A permanent green badge hides failed updates and lost connections. Keep a compact card instead:
* The release, the size, the endpoint, and "Last report" from `lastHeartbeatAt`.
* Your own **Active** flag, from [Verify and activate](/docs/deployment-ux/verify-and-activate).
* The last confirmed state with its time, instead of a guess.
## Updates that need the admin [#updates-that-need-the-admin]
A release that adds a permission or a [frozen resource](/docs/guide/frozen-and-live) can't apply on its own: only the admin's initial setup can create those. The deployment stays `running` on the old release, and `updateState.active` explains why. The example at the top of this page shows this state.
**Show it when** `updateState.active.status` is `blocked`. `blocker.kind` says why: `setup`, `input`, or `other`.
* **Still running.** The deployment stays `running` on the old release. A page that reads only `status` hides the wait.
* **Retry doesn't help.** Only the admin rerunning initial setup, or the missing input, moves the update.
* **Missing input.** For `blocker.kind: "input"`, name the input from `blocker.inputId` and ask for its value.
* **Hold other changes.** Disable size changes until the blocker clears, and say why.
* **Name both releases.** "Version 12 keeps running. Version 13 needs your AWS admin."
```json title="GET /v1/deployments/{id}"
{
"status": "running",
"updateState": {
"active": {
"id": "duop_1x8kq2m4n6p8r0t2v4w6y8z0ab",
"status": "blocked",
"reasons": ["release"],
"targetReleaseId": "rel_13…",
"blocker": { "kind": "setup", "message": "Permission profile \"application\" was modified." },
"actionRequired": "Apply the updated setup to continue this update.",
"requestedAt": "2026-01-21T08:30:00Z"
},
"next": null,
"latest": null
}
}
```
| Field | Meaning |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `status` | `blocked`, `queued`, `applying`, `succeeded`, `failed`, or `superseded` |
| `blocker.kind` | `setup`: the admin must rerun initial setup. `input`: a value is missing (`blocker.inputId`). `other`: read `blocker.message`. |
| `blocker.message` | What changed, for the admin |
| `actionRequired` | The next step, written for people |
| `targetReleaseId` | The release waiting to apply |
**Review the setup change** sends the admin to the [deployment portal](/docs/deployment-ux/deployment-portal) with a setup link, where **Continue update** shows the steps for their setup method.
## Not reporting [#not-reporting]
`lastHeartbeatAt` is the last time the deployment reported. The service keeps running when it loses its connection to Alien, so a late report doesn't prove an outage.
Example UI: Not reporting
```text
Document processing · Not reporting
We can’t confirm the current state
The service last reported 25 minutes ago. It may still be running.
Last confirmed: Running version 12 at 09:21
Last report: 25 minutes ago
[Check now] [Contact support]
```
**Show it when** `lastHeartbeatAt` is older than your threshold, such as 10 minutes.
* **Not an outage.** A stale heartbeat means Alien can't see the deployment. The service may still serve requests.
* **Last confirmed state.** Show what you last knew with its time: "Running version 12 at 09:21."
* **No traffic switch.** Don't move traffic away on a stale heartbeat alone. Use your own health check for that.
* **Heartbeats off.** With `stackSettings.heartbeats: "off"`, the customer turned health checks off. Don't show a stale heartbeat as a warning.
```json title="GET /v1/deployments/{id}"
{
"status": "running",
"lastHeartbeatAt": "2026-01-15T09:21:03Z",
"stackSettings": { "heartbeats": "on" }
}
```
Pick a threshold, such as 10 minutes, and use it for a "Last report" line:
```ts title="lib/connection.ts"
const STALE_AFTER_MS = 10 * 60 * 1000
export function connection(lastHeartbeatAt: Date | null) {
if (!lastHeartbeatAt) return { label: "Not connected yet" }
const age = Date.now() - lastHeartbeatAt.getTime()
if (age > STALE_AFTER_MS) return { label: "Not reporting", lastHeartbeatAt }
return { label: "Connected", lastHeartbeatAt }
}
```
## Test it [#test-it]
* **Block an update.** Release a version that adds a permission, and check your page says the admin must act while the old version keeps serving.
* **Stop the heartbeat clock.** Set `lastHeartbeatAt` 25 minutes back in a test response. The page says **Not reporting**, not **Down**.
* **Heartbeats off.** Set `stackSettings.heartbeats` to `off`, and confirm the page shows no warning.
## Next [#next]
* [Show deployment progress](/docs/deployment-ux/deployment-progress): the states before ready
* [Resize a deployment](/docs/deployment-ux/resize-a-deployment): size changes after initial setup
* [Releases that need initial setup again](/docs/production/customer-setup#releases-that-need-initial-setup-again): what your team sees
# Verify and activate (/docs/deployment-ux/verify-and-activate)
Activation is the moment your product turns the feature on for a customer, after its own check proves the new deployment works. It applies when you launch the portal from your product with a [Deploy button](/docs/deployment-ux/deploy-button), and when you build it into your product with [your own setup screen](/docs/deployment-ux/setup-screen).
Example UI: Ready
```text
Document processing · Ready to connect
Deployed. Run a connection test.
The service is running in your AWS account. Test it from your app before you switch traffic over.
Resources
▸ Compute (3) Running
▸ Data (1) Running
▸ Secrets and keys (2) Running
▸ Networking and access (3) Running
Endpoint: https://docs.customer-1.internal
Version: 12
Connection test: Not run
[Run connection test]
```
**Show it when** `status` is `running`, `currentReleaseId` equals `desiredReleaseId`, and your check hasn't passed yet.
* **No endpoint yet.** The `info` response can lack a public endpoint for `api`. Keep **Ready to connect** and read it again.
* **Check failed.** Show the real error ("Health check returned 503") with **Test again**. Don't offer a deployment retry: the deployment is fine.
* **Private endpoint.** A check from your backend can't reach it. Run it from the customer's network, or show instructions instead of a test button.
* **Wrong target.** A fallback to your hosted service can answer the check. Make the check prove this deployment served it.
```json title="GET /v1/deployments/{id}/info"
{
"status": "running",
"platform": "aws",
"resources": {
"api": {
"type": "container",
"publicEndpoints": {
"app": { "url": "https://app.k3x9.deploy.acme.com", "protocol": "http", "host": "app.k3x9.deploy.acme.com", "port": 443 }
}
}
}
}
```
`running` means Alien finished deploying. It doesn't prove that your product can reach the service, that DNS resolves from where it calls, or that the customer's data is in place.
## Read the endpoint [#read-the-endpoint]
**Server.** Read the deployment's public endpoints. Resource IDs come from your `alien.ts`; here the container is `api`, and `publicEndpoints` is keyed by the name you gave `.publicEndpoint()`.
`GET /v1/deployments/{id}/info`
TypeScript:
```ts title="lib/endpoint.ts"
import { alien } from "./alien"
export async function getApiUrl(deploymentId: string) {
const info = await alien.deployments.getInfo({ id: deploymentId })
const api = info.resources["api"]?.publicEndpoints
return api ? Object.values(api)[0]?.url : undefined
}
```
Python:
```python title="endpoint.py"
from alien_client import alien
def get_api_url(deployment_id: str) -> str | None:
r = alien.get(f"/v1/deployments/{deployment_id}/info")
r.raise_for_status()
endpoints = (r.json()["resources"].get("api") or {}).get("publicEndpoints") or {}
return next((e["url"] for e in endpoints.values()), None)
```
Rust:
```rust title="src/endpoint.rs"
use serde_json::Value;
use crate::alien::{client, API};
pub async fn get_api_url(deployment_id: &str) -> reqwest::Result> {
let info: Value = client()
.get(format!("{API}/v1/deployments/{deployment_id}/info"))
.send().await?.error_for_status()?.json().await?;
let endpoints = info["resources"]["api"]["publicEndpoints"].as_object();
Ok(endpoints.and_then(|e| e.values().next()).and_then(|e| e["url"].as_str()).map(str::to_owned))
}
```
`alien.deployments.getInfo({ id })` returns one deployment's endpoints with your API key. Don't confuse it with `alien.deployment.getInfo()` (`GET /v1/deployment-info`), the setup context the portal reads with a setup token.
See [Networking](/docs/guide/networking) for public and private endpoints.
## Activate [#activate]
**Server.** Run a check from your backend, then set your own flag:
TypeScript:
```ts title="lib/activate.ts"
import { getApiUrl } from "./endpoint"
export async function verifyAndActivate(customerId: string, deploymentId: string) {
const url = await getApiUrl(deploymentId)
if (!url) return { ok: false, reason: "No endpoint yet" }
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(10_000) })
if (!res.ok) return { ok: false, reason: `Health check returned ${res.status}` }
await db.customers.update(customerId, { byocEndpoint: url, byocActive: true })
return { ok: true }
}
```
Python:
```python title="activate.py"
import httpx
from endpoint import get_api_url
def verify_and_activate(customer_id: str, deployment_id: str) -> dict:
url = get_api_url(deployment_id)
if not url:
return {"ok": False, "reason": "No endpoint yet"}
res = httpx.get(f"{url}/health", timeout=10)
if not res.is_success:
return {"ok": False, "reason": f"Health check returned {res.status_code}"}
db.customers.update(customer_id, byoc_endpoint=url, byoc_active=True)
return {"ok": True}
```
Rust:
```rust title="src/activate.rs"
use std::time::Duration;
use anyhow::Context;
use crate::endpoint::get_api_url;
// Returns the endpoint to store with your active flag.
pub async fn verify_endpoint(deployment_id: &str) -> anyhow::Result {
let url = get_api_url(deployment_id).await?.context("No endpoint yet")?;
let res = reqwest::Client::new()
.get(format!("{url}/health"))
.timeout(Duration::from_secs(10))
.send()
.await?;
anyhow::ensure!(res.status().is_success(), "Health check returned {}", res.status());
Ok(url)
}
```
* **Your product decides.** Run activation automatically after a passing check, or show a **Finish setup** button that runs it.
* **Two facts on your page.** The deployment status from Alien, and your own **Active** flag. Until the check passes, show **Ready to connect**, not **Active**.
* **Switch traffic last.** Send work to the deployment only after activation.
## Test it [#test-it]
* **Fail the check.** Point it at a path that returns 503, and confirm the feature stays off with the real error on the page.
* **Private endpoint.** Deploy with `endpointAccess: "private"`, and confirm your page shows instructions instead of a test your backend can't run.
* **Pass the check.** Confirm your page moves to **Active** and your product sends work to the deployment.
## Next [#next]
* [Show deployment progress](/docs/deployment-ux/deployment-progress): the states before ready
* [Updates and connection](/docs/deployment-ux/updates-and-connection): what to show after activation
* [Remove a deployment](/docs/deployment-ux/remove-a-deployment): switch traffic off before removal
# Who acts next (/docs/deployment-ux/who-acts-next)
A good deployment card answers two questions at a glance: can the customer use the service, and who must act next. These practices apply to every [Deployment UX option](/docs/deployment-ux) that shows status in your product: a Deploy button and your own setup screen.
Example UI: Update needs the admin
```text
Document processing · Action needed
Version 13 needs your AWS admin
Version 12 keeps running. Version 13 adds a permission, so an admin must update the setup in AWS.
[Review the setup change] [Send to an admin]
```
The card above answers both: version 12 still serves, and the AWS admin acts.
**Show it when** `updateState.active.status` is `blocked` and `blocker.kind` is `setup`, while `status` stays `running`.
* **Read `updateState`.** A page that reads only `status` shows "Running" and hides the wait.
* **No Retry.** Only the admin rerunning initial setup moves the update.
* **Hand off.** **Send to an admin** shares the link to the portal's **Continue update** step.
```json title="GET /v1/deployments/{id}"
{
"status": "running",
"updateState": {
"active": {
"status": "blocked",
"blocker": { "kind": "setup", "message": "Permission profile \"application\" was modified." },
"actionRequired": "Apply the updated setup to continue this update."
}
}
}
```
## Name the actor [#name-the-actor]
Every state has one actor: the customer's admin, Alien, or your team. Put that in words. A customer who sees a spinner for an hour can't tell whether to wait, call their admin, or call you.
| Actor | States |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| The customer's admin | `pending`, `initial-setup`, `waiting-for-secrets`, a `blocked` update, `teardown-required`, `retention-required`, `teardown-failed` |
| Alien | `provisioning`, `waiting-for-machines`, `update-pending`, `updating`, `delete-pending`, `deleting` |
| Your team | Other failures, `delete-failed`, and `running` before your connection test passes |
A failure with an `error.hint` about a quota or permission belongs to the admin first. See the [status map](/docs/deployment-ux/deployment-progress#status-map).
## Hand off to the admin [#hand-off-to-the-admin]
The person who clicks **Deploy** is often not the person who can create IAM roles. A blocked champion abandons initial setup.
* **Offer Send to an admin** next to every action the admin must take.
* **Send the link or the exact command,** never a screenshot of your page.
* **Say what the admin will do,** in one line: "Creates an IAM role and a VPC in your AWS account. About 15 minutes."
* **Show the link only to signed-in members** of that organization: anyone with it can deploy for the customer.
See [Add a Deploy button](/docs/deployment-ux/deploy-button#send-the-admin-to-the-portal).
## Keep the card honest [#keep-the-card-honest]
After initial setup, keep a compact card for the life of the deployment. A permanent green badge hides failed updates and lost connections.
* **`running` isn't active.** Keep your own **Active** flag, set when your connection test passes. See [Verify and activate](/docs/deployment-ux/verify-and-activate).
* **A stale heartbeat isn't an outage.** Say "We can't confirm the current state", never **Down**.
* **A blocked update keeps the old release.** Say both halves: "Version 12 keeps running. Version 13 needs your AWS admin."
* **Removal waits on the admin.** Show **Removed** only at `deleted` or `retained`. See [Follow removal](/docs/deployment-ux/follow-removal).
## Test it [#test-it]
* **Feed your page every response** from [Show deployment progress](/docs/deployment-ux/deployment-progress) and [Follow removal](/docs/deployment-ux/follow-removal). Check each headline names who acts next.
* **Send to an admin.** Sign in as another organization and confirm it can't see the first organization's link.
## Next [#next]
* [Status messages](/docs/deployment-ux/status-messages): the words to use
* [Links and branding](/docs/deployment-ux/links-and-branding): checks before your first customer
# AI API reference (/docs/infrastructure/ai/api)
The AI API is how your code calls models through a linked [AI](/docs/infrastructure/ai) resource: an SDK client, the binding it reads, and the gateway endpoints underneath. A **binding** is the connection details Alien injects into each workload [linked](/docs/infrastructure/using-resources#link) to the resource, which the SDK client reads.
In TypeScript, `ai(name)` from `@alienplatform/sdk` returns a client. In Python, `ai(name).connection()` from `alienplatform` returns an endpoint for any OpenAI- or Anthropic-compatible client.
There is no Rust client. Rust and other languages call the [endpoints](#endpoints) directly.
## The binding [#the-binding]
Linking an AI resource injects `ALIEN__BINDING`: the resource id uppercased, with hyphens replaced by underscores. A resource named `llm` becomes `ALIEN_LLM_BINDING`.
The value is a JSON object tagged by `service`:
| `service` | Platform | Fields |
| ------------- | ---------------------------------------------------- | --------------------- |
| `bedrock` | AWS | `region` |
| `vertex` | GCP | `project`, `location` |
| `foundry` | Azure | `endpoint`, `account` |
| `external-ai` | Kubernetes, Machines, Local, or any external binding | `provider`, `apiKey` |
The cloud variants carry no credential: the workload's own identity authorizes each call. `external-ai` carries a provider key, and the SDK calls that provider directly. `provider` is `openai` or `anthropic`.
## TypeScript [#typescript]
### ai [#ai]
```ts
function ai(name: string): Ai
```
Returns a client for the AI resource `name`. The binding is resolved, and on a cloud binding the gateway is started, on the first call. A failed start isn't cached, so the next call retries.
### chat.completions.create [#chatcompletionscreate]
```ts
create(
params: ChatCompletionCreateParams & { stream?: false },
): Promise
create(
params: ChatCompletionCreateParams & { stream: true },
): Promise>
```
Sends an OpenAI Chat Completions request. Works for every model. Models that don't speak Chat Completions natively, Claude and the GPT-5 family, are reached through [protocol translation](/docs/infrastructure/ai/behavior#protocols).
```ts
const llm = ai("llm")
const completion = await llm.chat.completions.create({
model: "gpt-oss-120b",
messages: [{ role: "user", content: "Summarize this order." }],
})
const stream = await llm.chat.completions.create({
model: "gpt-oss-120b",
messages: [{ role: "user", content: "Summarize this order." }],
stream: true,
})
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "")
}
```
| Parameter | Type | Required | Notes |
| ---------- | ---------------------------------------------------- | -------- | --------------------------------------------------------------------- |
| `model` | `string` | Yes | An id from `getAvailableModels()`, with or without the `byo/` prefix. |
| `messages` | `Array<{ role: string; content: string \| object }>` | Yes | `content` takes a string or content parts. |
| `stream` | `boolean` | No | `true` returns an async iterable of chunks. |
Any other field is passed through. On a model's native protocol it reaches the provider unchanged.
### responses.create [#responsescreate]
```ts
create(params: ResponseCreateParams & { stream?: false }): Promise
create(params: ResponseCreateParams & { stream: true }): Promise>
```
Sends an OpenAI Responses request. Native on AWS for the GPT-5 family, `gpt-oss-20b`, and `gpt-oss-120b`. Other models are reached through translation.
```ts
const response = await ai("llm").responses.create({
model: "gpt-5.5",
input: "Summarize this order.",
})
```
| Parameter | Type | Required | Notes |
| --------- | ---------------------------------------------------- | -------- | ------------------------------------------- |
| `model` | `string` | Yes | A model id. |
| `input` | `string \| Array<{ role: string; content: string }>` | Yes | The prompt. |
| `stream` | `boolean` | No | `true` returns an async iterable of events. |
Throws `AI_RESPONSES_API_UNSUPPORTED` on an external binding whose provider is `anthropic`, since Anthropic's API has no Responses endpoint.
### getAvailableModels [#getavailablemodels]
```ts
getAvailableModels(): Promise
```
Lists the models for this deployment.
```ts
const models = await ai("llm").getAvailableModels()
// [{ id: "byo/gpt-oss-20b", provider: "openai", displayName: "GPT-OSS 20B" }, …]
```
On a cloud binding this is Alien's model list for that cloud, with ids in the `byo/` namespace. It doesn't call the provider.
On an external binding it's a fixed list per provider: `gpt-4o-mini` and `gpt-4o` for `openai`, and `claude-opus-4-8`, `claude-sonnet-5`, and `claude-haiku-4-5` for `anthropic`. See [Model availability](/docs/infrastructure/ai/behavior#model-availability).
### getAiConnection [#getaiconnection]
```ts
function getAiConnection(name: string): Promise
```
Resolves a binding to an endpoint for another client library. On a cloud binding it waits for the gateway to start, so the URL is live when the promise resolves.
```ts
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { getAiConnection } from "@alienplatform/sdk"
const connection = await getAiConnection("llm")
const model = createOpenAICompatible({
name: "alien",
...connection,
apiKey: connection.apiKey ?? "",
})("gpt-oss-120b")
```
Pass `apiKey: ""` on a cloud binding instead of leaving it unset. Most clients fall back to a provider key in the environment when the field is missing.
### Types [#types]
```ts
interface AiConnection {
baseURL: string // ends in /v1
apiKey?: string // external bindings only; undefined on the clouds
}
interface AiModel {
id: string // pass as `model`
provider: string // "openai", "anthropic", "google", "mistral", …
displayName: string // label for a model picker
}
interface ChatCompletionCreateParams {
model: string
messages: Array<{ role: string; content: string | object }>
stream?: boolean
[key: string]: unknown
}
interface ResponseCreateParams {
model: string
input: string | Array<{ role: string; content: string }>
stream?: boolean
[key: string]: unknown
}
```
`ChatCompletion`, `ChatCompletionChunk`, `Response`, and `ResponseStreamEvent` come from the `openai` package, an optional peer dependency. Install it for the types. It isn't needed at runtime.
## Python [#python]
```python
from alienplatform import ai
conn = await ai("llm").connection()
```
`connection()` returns an `AiConnection`:
| Field | Type | Notes |
| ----------------- | ------------- | -------------------------------------------------------------- |
| `base_url` | `str` | The gateway or provider root, without `/v1`. |
| `openai_base_url` | `str` | `base_url` with `/v1` appended, for OpenAI-compatible clients. |
| `api_key` | `str \| None` | External bindings only. `None` on the clouds. |
| `provider` | `str \| None` | External bindings only. |
On a cloud binding, the gateway runs inside your Python process for as long as the connection object is alive. The `repr` of a connection redacts the key.
## Endpoints [#endpoints]
The gateway serves these paths under the connection's base URL. `getAiConnection()` returns a `baseURL` that already ends in `/v1`.
| Path | Method | Protocol |
| ------------------- | ------ | ---------------------------------------------------- |
| `/chat/completions` | POST | OpenAI Chat Completions |
| `/messages` | POST | Anthropic Messages |
| `/responses` | POST | OpenAI Responses |
| `/models` | GET | The model list, as `{ object: "list", data: [...] }` |
Every inference path accepts every model. The `model` field in the body picks the model, and the gateway translates when the path isn't the model's native protocol.
```ts
const { baseURL } = await getAiConnection("llm")
await fetch(`${baseURL}/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "gpt-oss-120b",
messages: [{ role: "user", content: "hi" }],
}),
})
```
On a cloud binding, send no credentials. The gateway listens only on the loopback interface inside your workload and signs every provider request with the workload's identity.
On an external binding the base URL is the provider's own (`https://api.openai.com/v1` or `https://api.anthropic.com/v1`), and you send the key as a bearer token.
Don't hardcode the gateway port. It's assigned when the gateway starts. When `ALIEN_AI_GATEWAY_URL` is set, append `//v1`, where `` is the resource id lowercased with underscores replaced by hyphens.
## Errors [#errors]
### SDK errors [#sdk-errors]
| Code | Meaning | Retryable |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------- |
| `AI_UPSTREAM_ERROR` | The gateway or provider returned a non-2xx status, which the error carries. | On 429, 502, 503, 504 |
| `AI_TRANSPORT_ERROR` | The request didn't complete, or the body wasn't valid JSON. | Yes |
| `AI_RESPONSES_API_UNSUPPORTED` | `responses.create` on an external `anthropic` binding. | No |
| `AI_UNSUPPORTED_PROVIDER` | An external binding names a provider other than `openai` or `anthropic`, and `ALIEN_AI_LOCAL_BASE_URL` isn't set. | No |
| `AI_GATEWAY_START_FAILED` | The gateway process didn't report ready. | Yes |
| `AI_GATEWAY_UNSUPPORTED_PLATFORM` | No prebuilt gateway binary for this OS and architecture. | No |
| `AI_GATEWAY_BINARY_UNAVAILABLE` | The gateway binary couldn't be found or extracted. | No |
| `BINDING_NOT_FOUND` | `ALIEN__BINDING` isn't set. The resource isn't linked to this workload. | No |
| `INVALID_BINDING_CONFIG` | The binding JSON is malformed. | No |
Match on `error.code`. `AiUpstreamError` and `AiTransportError` are exported from `@alienplatform/sdk`.
### Gateway responses [#gateway-responses]
When the provider rejects a request, the gateway returns a stable JSON error instead of the provider's body:
```json
{
"type": "error",
"error": { "type": "provider_rate_limited", "code": "provider_rate_limited", "message": "The customer model provider rate limit was reached" },
"retryable": true
}
```
| Provider status | Gateway status | `code` | Retryable |
| --------------- | -------------- | ----------------------------- | ------------------------------------- |
| 401, 403, 404 | 503 | `provider_access_unavailable` | No |
| 429 | 429 | `provider_rate_limited` | Yes. `Retry-After` is passed through. |
| 408, 504 | 504 | `provider_timeout` | Yes |
| Other 5xx | 502 | `provider_unavailable` | Yes |
| Other 4xx | Same status | `provider_request_rejected` | No |
Errors the gateway raises itself:
| Code | Status | Meaning |
| ---------------------------------------- | ------ | ----------------------------------------------------------------------------------------- |
| `GATEWAY_INVALID_REQUEST` | 400 | The body isn't JSON, has no `model`, or uses a field the target protocol can't represent. |
| `GATEWAY_REQUEST_TOO_LARGE` | 413 | The body is over 32 MiB. |
| `GATEWAY_MODEL_NOT_AVAILABLE` | 404 | The model isn't in Alien's list for this cloud. |
| `GATEWAY_UNKNOWN_BINDING` | 404 | No AI resource with that name is linked. |
| `GATEWAY_UPSTREAM_FAILED` | 502 | The request to the provider didn't complete. Retryable. |
| `GATEWAY_AMBIENT_CREDENTIAL_UNAVAILABLE` | | The workload's cloud credential couldn't be obtained. Retryable. |
| `GATEWAY_WORKLOAD_IDENTITY_INVALID` | | The workload identity can't authorize this binding. |
| `GATEWAY_BINDING_CONFIG_INVALID` | 400 | The binding couldn't be parsed. |
# AI behavior and limits (/docs/infrastructure/ai/behavior)
An [AI](/docs/infrastructure/ai) resource lets your code call the customer's models with no key to leak, and Alien adds no limits of its own. Below are its guarantees, its limits, how it translates between protocols, and what it does on each cloud.
## Guarantees [#guarantees]
**No keys in your application.** On AWS, GCP, and Azure, the workload's own identity (IAM role, service account, or managed identity) authorizes every call. Nothing in the binding is a credential, so there is no key to leak, rotate, or scope. Inference bills to the customer's cloud account.
**A private endpoint.** The gateway listens only on the loopback interface inside the workload it's linked to. It's never exposed on the network.
**Native requests pass through unchanged.** When you call a model on its native protocol, the request body reaches the provider as you wrote it, including fields Alien doesn't know about, and the reply streams back unchanged. The one exception is Claude on AWS, described under [Protocols](#protocols).
**Translation fails loudly.** When you call a model on another protocol, Alien translates only fields that mean the same thing on both sides. A field that can't be represented returns a 400 before any request reaches the provider, so nothing is billed.
## Limits [#limits]
| Limit | Value | Notes |
| -------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Request body | 32 MiB | Returns 413. Providers cap requests below this (Bedrock `InvokeModel` at 25 MB), so their limit is usually the one you hit. |
| Model id | Must be in Alien's list for that cloud | Any other id returns 404 without reaching the provider. |
| Default output tokens | 4,096 | Applied only when a request translated to Anthropic Messages has no output limit, since Messages requires one. |
| Azure throughput per model | 1 `GlobalStandard` unit | A conservative default. Raise it per model in the Azure portal. A re-provision resets it. |
| Resource id | `[A-Za-z0-9-_]`, up to 64 characters | Immutable after create. |
**Beyond the body cap, Alien adds no limits to inference.** There is no rate limit, request timeout, concurrency cap, or token ceiling.
The provider's quotas apply directly, and a provider 429 reaches you as a 429 with its `Retry-After` header. If your workload is public, put your own limits in front of it.
**Changing a live resource.** The id is immutable and there is nothing else to configure, so there is no resize, version pin, or update that can disrupt a running workload.
## Protocols [#protocols]
Each model has one native protocol:
| Models | Native protocol | Endpoint |
| ----------------------------------- | ------------------------------ | ---------------------- |
| Claude, on every cloud | Anthropic Messages | `/v1/messages` |
| GPT-5 family (AWS) | OpenAI Responses | `/v1/responses` |
| `gpt-oss-20b`, `gpt-oss-120b` (AWS) | Chat Completions and Responses | either |
| Everything else | OpenAI Chat Completions | `/v1/chat/completions` |
The gateway picks the model from the `model` field in the body, not from the path. When the path isn't the model's native protocol, the gateway translates the request and the response.
Translation keeps only portable fields. These fail with a 400 when translated:
* Streaming with tools. Use a non-streaming request, or call the model on its native protocol.
* Built-in Responses tools, such as web search. Function tools translate.
* Anthropic-only fields: `cache_control`, `thinking`, `container`, `mcp_servers`, `context_management`, `inference_geo`. Message `metadata` other than `user_id`.
* OpenAI-only fields such as `audio`, `logprobs`, `logit_bias`, `frequency_penalty`, and the legacy `functions` and `function_call`.
* Responses state: `store`, `background`, `truncation` other than `disabled`, `include`, `metadata`, `prompt_cache_key`, `max_tool_calls`, reasoning summaries, and `text.verbosity`.
* Images sent to Claude as URLs. Only base64 `data:` URLs translate.
The error names the field. Remove it, or call the model on its native endpoint.
### Claude on AWS [#claude-on-aws]
Bedrock serves Claude through an older Anthropic schema, so Alien adapts the request on AWS:
* Anthropic's server-executed tools, such as web search and code execution, are dropped. Client-executed tools that Bedrock hosts (`bash_`, `text_editor_`, `computer_`, `memory_`, `tool_search_`) are kept.
* `output_config` and `context_management` are dropped. A `thinking` mode other than `enabled` or `disabled` is dropped.
* Mid-conversation system messages are folded into the turn before them.
Those edits apply only on AWS. On every cloud, Alien forwards only a known set of `anthropic-beta` families: `claude-code-`, `computer-use-`, `context-1m-`, `context-management-`, `fine-grained-tool-streaming-`, `interleaved-thinking-`, `output-128k-`, `token-efficient-tools-`, and `tool-examples-`. Other betas are dropped.
### Model id spellings [#model-id-spellings]
The gateway accepts the spellings that Claude clients send. `claude-haiku-4-5`, `claude-sonnet-4-5-20250929`, `claude-sonnet-4-5@20250929`, and `us.anthropic.claude-haiku-4-5-20251001-v1:0` all resolve to Alien's ids (`claude-haiku-4.5`, `claude-sonnet-4.5`). A `byo/` prefix is accepted on any id.
## Model availability [#model-availability]
Inside the workload, `getAvailableModels()` returns Alien's model list for the cloud. It doesn't call the provider, so it's instant and never fails because of the cloud.
A model the customer hasn't enabled is still listed. Calling it returns the provider's error, which the gateway reports as `provider_access_unavailable`. Handle that error for a model you haven't called before.
Separately, Alien checks real availability every 15 minutes, outside the workload, and reports it in the resource's status:
| Cloud | How it checks | What it reports |
| ----- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | `GetFoundationModelAvailability` per model, 8 at a time | Agreement, entitlement, authorization, and region status. The GPT-5 family has no foundation-model record, so it's reported as unknown. |
| GCP | The Model Garden catalogs for the `google` and `anthropic` publishers | Whether each model is listed, and for Claude, whether Anthropic's terms are accepted. |
| Azure | The account's deployments | Available when a deployment with the model's name reached `Succeeded`. |
The check only reads the provider's management APIs. It never calls a model or uses quota.
When any model's status is unknown, the resource reports degraded health with "Some model availability could not be observed". A missing model never fails a deploy on AWS or GCP.
Claude is the one family behind a one-time customer step on every cloud. See [Models](/docs/infrastructure/ai/models).
## Platforms [#platforms]
### AWS: Amazon Bedrock [#aws-amazon-bedrock]
Nothing is provisioned. Deploying the resource grants the workload `ai/invoke` on `foundation-model/*`, and deleting it removes nothing.
* AWS has the widest model list, including the GPT-5 family and every Claude model.
* Claude calls go through Bedrock `InvokeModel` using cross-region inference profiles.
* The GPT-5 family and Responses calls go to the `bedrock-mantle` endpoint.
### GCP: Vertex AI [#gcp-vertex-ai]
Alien enables `aiplatform.googleapis.com` and grants a custom role limited to prediction, instead of the broad `roles/aiplatform.user`. Deleting the resource leaves the API enabled, since other stacks may use it. Gemini and Claude are available.
### Azure: Azure AI Foundry [#azure-azure-ai-foundry]
Azure is the only cloud where Alien creates something lasting: an `AIServices` account (S0) and deployments of `gpt-4.1` and `model-router`, each at 1 `GlobalStandard` unit. The workload gets the Cognitive Services User role on the account.
The account appears in the customer's portal and on their bill. Deleting the resource deletes the account and its deployments.
Alien creates the deployments together. If any deployment ends in `Failed` or `Canceled`, the resource fails, for example when the region doesn't offer the model at `GlobalStandard` or quota is zero.
Nothing checks this before the account is created. See [Models](/docs/infrastructure/ai/models#azure-azure-ai-foundry) for how to check a region first.
Claude needs Marketplace terms accepted in the portal, which no API can do, so the customer deploys it there once.
### Kubernetes and Machines [#kubernetes-and-machines]
Alien doesn't provision AI. Supply an external AI binding with your provider key at deploy time, and the SDK calls that provider directly.
Add the binding together with the resource. Without one, the deployment fails at deploy time, not at validation.
### Local [#local]
An external binding using OpenAI, with the key from `OPENAI_API_KEY`. `alien dev` fails with an actionable error if it's unset.
`getAvailableModels()` returns a short built-in list. Set `ALIEN_AI_LOCAL_BASE_URL` to point at any OpenAI-compatible server, including one on your machine.
## Design decisions [#design-decisions]
**No configuration on the resource.** Model, temperature, and every other setting belong to the request, so changing models doesn't need a release, and one stack works across three clouds whose model menus differ.
**Native first, translation second.** Calling a model on its native protocol gives you its full API, including fields Alien hasn't seen. Translation lets one client reach every model, but only for fields that mean the same thing on both sides; anything else fails before it's sent.
**The in-workload list isn't probed.** Probing models from inside the workload would add latency and spend quota on startup. Alien checks availability in the background instead, outside the workload, and the workload lists the catalog.
**No Alien rate limit.** Any limit Alien imposed would be a second ceiling below the provider's real quota, and a number you couldn't plan against.
**Delete removes the Azure account.** On Azure, deleting the resource deletes the AI Foundry account and its deployments, like every other Alien resource. AWS and GCP have nothing to delete.
# AI (/docs/infrastructure/ai)
AI is a resource that gives your code model inference through the customer's own Amazon Bedrock, Vertex AI, or Azure AI Foundry, with no API key in your application. It fits when your product calls language models from inside a deployment and the customer wants inference on their account, under their quotas and data-handling terms.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const llm = new alien.AI("llm").build()
const api = new alien.Worker("api")
.code({ type: "source", src: "./api", toolchain: { type: "typescript" } })
.link(llm)
.permissions("execution")
.build()
export default new alien.Stack("acme")
.platforms(["aws", "gcp", "azure"])
.add(llm, "frozen")
.add(api, "live")
.permissions({ profiles: { execution: { llm: ["ai/invoke"] } } })
.build()
```
In this stack:
* `.link(llm)` connects the AI resource to the `api` Worker, the **workload** (the running code) that uses it. Alien injects the AI resource's connection details, its **binding**, into the Worker, read by the SDK client.
* The `execution` [permission profile](/docs/guide/permissions), the named set of cloud permissions a workload runs with, grants `ai/invoke`: calling models.
* `"frozen"` means the customer's admin creates the AI resource during initial setup. `"live"` resources, like the Worker, are created and updated by Alien on every release. See [Frozen and live](/docs/guide/frozen-and-live).
The `api` Worker calls a model with an OpenAI-style client:
```ts title="api/src/summarize.ts"
import { ai } from "@alienplatform/sdk"
const completion = await ai("llm").chat.completions.create({
model: "gpt-oss-120b",
messages: [{ role: "user", content: "Summarize this order." }],
})
```
## Platforms [#platforms]
`llm` becomes the AI service in the customer's cloud. Calls are billed to their cloud account and authorized by the workload's own identity.
| Platform | Backing service | What Alien provisions |
| -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------- |
| AWS | Amazon Bedrock | Nothing. Bedrock is an account-level API, so Alien only grants the workload access. |
| GCP | Vertex AI | Enables the Vertex AI API and grants a custom prediction-only role. |
| Azure | Azure AI Foundry | An AI Services account with `gpt-4.1` and `model-router` deployments. |
| Kubernetes, Machines | Your provider | Nothing. Supply an external AI binding with your provider key. |
| Local | Your provider | Nothing. `alien dev` uses `OPENAI_API_KEY`, or any OpenAI-compatible server at `ALIEN_AI_LOCAL_BASE_URL`. |
Per-cloud details are in [Behavior and limits](/docs/infrastructure/ai/behavior#platforms). The model list for each cloud is in [Models](/docs/infrastructure/ai/models).
## Bring your own models [#bring-your-own-models]
AI is the in-deployment way for a customer to bring their own models. Each customer's inference runs in their account, and you ship no keys. The full flow, including open-weight models on the customer's GPUs, is in [Local AI models in a deployment](/docs/guide/local-ai-models/in-a-deployment).
If your product stays hosted in your cloud, use the AI Gateway instead. See [Local AI models](/docs/guide/local-ai-models).
## Model choice [#model-choice]
The model menu belongs to the customer's cloud, not to you. What you can call depends on which cloud they run and which models they've enabled.
If your product needs one model everywhere, or a provider none of the clouds host, use an [external binding](/docs/infrastructure/platforms/kubernetes#connect-the-customers-infrastructure) with your own provider key instead.
## Frozen or live [#frozen-or-live]
AI can be `"frozen"` or `"live"`. See [Frozen and live](/docs/guide/frozen-and-live).
`ai/invoke` grants inference and nothing else: no model management and no deployment writes.
## Options [#options]
| Member | Type | Notes |
| ------------------ | ------------------- | --------------------------------------------------------------------------------------- |
| `new alien.AI(id)` | `string` | Letters, digits, hyphens, and underscores, up to 64 characters. Immutable after create. |
| `.enabled(input)` | boolean stack input | Creates the resource only when the input is true. |
There is nothing else to configure. The model, temperature, and every other setting belong to the request.
Changing models doesn't need a release, and one stack works across clouds whose model menus differ.
## Use it in code [#use-it-in-code]
```ts title="src/summarize.ts"
import { ai } from "@alienplatform/sdk"
const llm = ai("llm")
const completion = await llm.chat.completions.create({
model: "gpt-oss-120b",
messages: [{ role: "user", content: "Summarize this order." }],
})
console.log(completion.choices[0]?.message.content)
```
```python title="summarize.py"
from alienplatform import ai
from openai import AsyncOpenAI
conn = await ai("llm").connection()
client = AsyncOpenAI(base_url=conn.openai_base_url, api_key=conn.api_key or "unused")
completion = await client.chat.completions.create(
model="gpt-oss-120b",
messages=[{"role": "user", "content": "Summarize this order."}],
)
```
On a cloud binding, the SDK starts a small gateway process beside your code on first use. It signs each request with the workload's cloud identity and forwards it to the provider. See the [API reference](/docs/infrastructure/ai/api).
### Any client library [#any-client-library]
`getAiConnection()` returns a base URL that works with any OpenAI- or Anthropic-compatible client, including the Vercel AI SDK, the OpenAI SDK, and the Anthropic SDK:
```ts title="src/claude.ts"
import Anthropic from "@anthropic-ai/sdk"
import { getAiConnection } from "@alienplatform/sdk"
const { baseURL, apiKey } = await getAiConnection("llm")
const anthropic = new Anthropic({ baseURL, apiKey: apiKey ?? "" })
const message = await anthropic.messages.create({
model: "claude-sonnet-4.6",
max_tokens: 1024,
messages: [{ role: "user", content: "Summarize this order." }],
})
```
Pass `apiKey: ""` on a cloud binding. Most clients otherwise fall back to a key from your environment, such as your personal `ANTHROPIC_API_KEY`, instead of the workload's identity.
### Pick a model at runtime [#pick-a-model-at-runtime]
Model availability differs per cloud and per account. List the models for this deployment instead of hardcoding one:
```ts
const models = await ai("llm").getAvailableModels()
// [{ id: "byo/gpt-oss-20b", provider: "openai", displayName: "GPT-OSS 20B" }, …]
```
Pass an id back as `model`, with or without the `byo/` prefix.
## Common patterns [#common-patterns]
**A model picker.** Feed `getAvailableModels()` to a dropdown and send the chosen id with each request. `displayName` is the human label.
**One client for every model.** Claude speaks Anthropic Messages, the GPT-5 family speaks OpenAI Responses, and the rest speak Chat Completions. Alien translates between them, so a Chat Completions client can call Claude.
Use the model's native protocol when you need provider-specific features such as extended thinking or prompt caching. See [Protocols](/docs/infrastructure/ai/behavior#protocols).
**Bring your own key off-cloud.** On Kubernetes, Machines, and Local, the same code calls your provider directly with your key. Your code doesn't change between environments.
## Local development [#local-development]
There is no cloud identity on your machine, so locally the resource uses your provider key:
```bash
OPENAI_API_KEY=sk-... alien dev
```
* `alien dev` fails with an actionable error if the key is missing.
* Cloud model ids don't apply locally. Pass any model id your key can reach.
* `getAvailableModels()` returns a short built-in list instead of querying the provider.
* To use another OpenAI-compatible server, including one on your machine, set `ALIEN_AI_LOCAL_BASE_URL`.
## Permissions [#permissions]
| Set | Grants |
| ------------------------------- | ---------------------------------------------------------------------- |
| `ai/invoke` | Calling models. |
| `ai/provision` | Creating the AI resource at initial setup. |
| `ai/management`, `ai/heartbeat` | Used by the management identity to read status and model availability. |
## Next [#next]
* [API reference](/docs/infrastructure/ai/api)
* [Behavior and limits](/docs/infrastructure/ai/behavior)
* [Models](/docs/infrastructure/ai/models)
* [Pricing](/docs/infrastructure/ai/pricing)
# AI models (/docs/infrastructure/ai/models)
Every cloud serves a different set of models, and some need a one-time customer step before they answer. Call `getAvailableModels()` at runtime instead of hardcoding an id.
The model ids below are Alien's own. The gateway maps each to the cloud's model id.
The native protocol decides the fastest path. Every model also answers on the other endpoints through translation. See [Protocols](/docs/infrastructure/ai/behavior#protocols).
## AWS: Amazon Bedrock [#aws-amazon-bedrock]
45 models.
| Family | Model ids | Native protocol |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| Claude | `claude-opus-5`, `claude-sonnet-5`, `claude-opus-4.8`, `claude-opus-4.7`, `claude-opus-4.6`, `claude-opus-4.5`, `claude-sonnet-4.6`, `claude-sonnet-4.5`, `claude-haiku-4.5`, `claude-fable-5` | Anthropic Messages |
| GPT-5 | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.4` | OpenAI Responses |
| GPT-OSS | `gpt-oss-20b`, `gpt-oss-120b` | Chat Completions and Responses |
| GPT-OSS Safeguard | `gpt-oss-safeguard-20b`, `gpt-oss-safeguard-120b` | Chat Completions |
| Qwen | `qwen3-32b`, `qwen3-coder-30b`, `qwen3-next-80b`, `qwen3-vl-235b` | Chat Completions |
| Mistral | `mistral-large-3`, `devstral-2`, `magistral-small`, `ministral-3-14b`, `ministral-3-8b`, `ministral-3-3b` | Chat Completions |
| Nemotron | `nemotron-nano-9b`, `nemotron-nano-12b`, `nemotron-nano-3-30b`, `nemotron-super-3-120b` | Chat Completions |
| MiniMax | `minimax-m2`, `minimax-m2.1`, `minimax-m2.5` | Chat Completions |
| Gemma | `gemma-3-4b`, `gemma-3-12b`, `gemma-3-27b` | Chat Completions |
| GLM | `glm-4.7`, `glm-4.7-flash`, `glm-5` | Chat Completions |
| DeepSeek | `deepseek-v3.2` | Chat Completions |
| Kimi | `kimi-k2.5` | Chat Completions |
| Palmyra | `palmyra-vision-7b` | Chat Completions |
### Prerequisites [#prerequisites]
| Step | Who | When |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| Model access | Nobody, for every model except Claude. Bedrock enables serverless models automatically in commercial regions. | |
| Claude agreement and use-case form | The customer, once per account | Before any Claude model answers |
| Quota | AWS sets per-model token and request quotas | See below |
Claude needs a model agreement per Claude model and an Anthropic use-case form submitted once for the account. Submitting the form from an AWS Organizations management account covers every member account. Neither step needs the console:
```bash
# once per account
aws bedrock put-use-case-for-model-access --form-data "$(base64 < form.json)"
# once per Claude model
OFFER=$(aws bedrock list-foundation-model-agreement-offers --model-id "$MODEL" \
--query 'offers[0].offerToken' --output text)
aws bedrock create-foundation-model-agreement --offer-token "$OFFER" --model-id "$MODEL"
aws bedrock get-foundation-model-availability --model-id "$MODEL"
```
Agreements are usage-priced with no fixed fee, so accepting one costs nothing until a call is made. `get-foundation-model-availability` reports agreement, authorization, entitlement, and region status separately, which tells you which one is missing. Alien runs the same check in the background and shows the result in the resource's status.
The GPT-5 family runs on a separate Bedrock endpoint with its own quotas. Raising a Claude quota does nothing for GPT-5, and the reverse.
**Check quota on a new AWS account.** Quotas are per model and per region, and AWS states that new accounts might receive reduced quotas.
A fresh account can be far below the published defaults, and inference then fails in a way that looks like a broken integration. Check Service Quotas for Amazon Bedrock first.
Availability also varies by region. A model in `us-east-1` may not exist in another region, so check Bedrock's regional availability table for the customer's region.
## GCP: Vertex AI [#gcp-vertex-ai]
15 models.
| Family | Model ids | Native protocol |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| Claude | `claude-opus-5`, `claude-sonnet-5`, `claude-opus-4.8`, `claude-opus-4.7`, `claude-opus-4.6`, `claude-opus-4.5`, `claude-sonnet-4.6`, `claude-sonnet-4.5`, `claude-haiku-4.5`, `claude-fable-5` | Anthropic Messages |
| Gemini | `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-flash-lite` | Chat Completions |
### Prerequisites [#prerequisites-1]
| Step | Who | When |
| ----------------------- | ----------------------------------------------- | ------------------------------- |
| Vertex AI API | Alien, at deploy | Automatic |
| Claude terms of service | The customer, once per project, in Model Garden | Before any Claude model answers |
| Quota | Per region, requested in the Cloud console | See below |
To enable Claude, the customer opens Vertex AI Model Garden, finds the model, selects **Enable**, and accepts Anthropic's terms. It's per project, so a customer who did it for one project still has to do it for another.
**Quota is per region, and the global endpoint has its own pool.** A project with room in `us-central1` can still be at zero on a global or multi-region endpoint. Check the quota page for the region the deployment runs in.
Gemini is GCP-only, though Google's Gemma models are on AWS.
Calls go to the location the deployment runs in. Google serves the 2.5 family in-region but the 3.x models only on its `global` location, so on a region-pinned deployment the 3.x models won't answer.
## Azure: Azure AI Foundry [#azure-azure-ai-foundry]
13 models.
| Family | Model ids | Native protocol |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| Claude | `claude-opus-5`, `claude-sonnet-5`, `claude-opus-4.8`, `claude-opus-4.7`, `claude-opus-4.6`, `claude-opus-4.5`, `claude-sonnet-4.6`, `claude-sonnet-4.5`, `claude-haiku-4.5`, `claude-fable-5` | Anthropic Messages |
| OpenAI | `gpt-4.1`, `gpt-4o-mini`, `model-router` | Chat Completions |
### Prerequisites [#prerequisites-2]
| Step | Who | When |
| --------------------------------------------------------- | ------------------------------------------------- | ------------------------------- |
| Foundry account, `gpt-4.1` and `model-router` deployments | Alien, at deploy | Automatic |
| `gpt-4o-mini` deployment | The customer, in the Foundry portal | Before it answers |
| Claude Marketplace terms and deployment | The customer, in the Foundry portal | Before any Claude model answers |
| Quota | Per model, per subscription, in tokens per minute | See below |
Alien deploys `gpt-4.1` and `model-router`. Every other model in the list answers once a deployment with that model's id as its name exists in the account.
Claude can't be automated: the first deployment requires accepting Marketplace terms, which is a portal action no API performs.
**Quota is per subscription.** It's tracked per model per subscription, and Global Standard deployments of the same model share one pool across regions. A new subscription can show 0 TPM in every region, which blocks deployment instead of throttling it.
Viewing quota needs the Cognitive Services Usages Reader role. Raising it needs Owner or Contributor.
`model-router` is one deployment that picks an underlying model per request, defaulting to the cheapest model within a narrow quality band of the best one. Its context window is that of the smallest model it can route to.
**Check the region before deploying.** Alien creates both deployments at the `GlobalStandard` tier and fails the resource if either fails. That happens mid-provision, after the account exists.
To check a region:
```bash
az rest --method get \
--url "https://management.azure.com/subscriptions//providers/Microsoft.CognitiveServices/locations//models?api-version=2024-10-01" \
--query "value[?model.name=='model-router'] | [0].model.skus[?name=='GlobalStandard']"
```
Run it for `gpt-4.1` too. An empty result means that region will fail.
## External bindings [#external-bindings]
Kubernetes, Machines, and Local use an external binding with your key, so you can call whatever the key can reach. `getAvailableModels()` returns a short built-in list instead of querying the provider:
| `provider` | Listed models |
| ----------- | -------------------------------------------------------- |
| `openai` | `gpt-4o-mini`, `gpt-4o` |
| `anthropic` | `claude-opus-4-8`, `claude-sonnet-5`, `claude-haiku-4-5` |
## When a model is missing [#when-a-model-is-missing]
A model that fails with `provider_access_unavailable`, or shows as blocked in the resource's status, is usually one of these:
1. **A one-time step is outstanding.** The agreement and use-case form on AWS, Model Garden terms on GCP, a portal deployment on Azure. This is the usual answer for Claude.
2. **Quota is zero.** Common on new accounts and subscriptions, on all three clouds. On AWS a model without access reads 0 applied quota, so a quota increase does nothing until access exists.
3. **The model isn't offered in that region.** Availability differs per region on every cloud.
On AWS and GCP none of these fail the deploy. The other models keep working, and the model answers once the customer completes the step. On Azure, a failed Alien-created deployment fails the resource.
## Sources [#sources]
Cloud requirements change. These are the pages to recheck.
* [Bedrock model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html), [automatic enablement](https://aws.amazon.com/about-aws/whats-new/2025/10/amazon-bedrock-automatic-enablement-serverless-foundation-models)
* [Bedrock runtime quotas](https://docs.aws.amazon.com/bedrock/latest/userguide/quotas-runtime.html), [regional availability by model](https://docs.aws.amazon.com/bedrock/latest/userguide/models-region-compatibility.html)
* [Claude on Vertex AI](https://cloud.google.com/blog/products/ai-machine-learning/global-endpoint-for-claude-models-generally-available-on-vertex-ai)
* [Foundry quotas and limits](https://learn.microsoft.com/en-us/azure/foundry/openai/quotas-limits), [model router](https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/model-router)
# AI pricing (/docs/infrastructure/ai/pricing)
Inference runs in the customer's cloud, so the customer pays their cloud provider for tokens on their own bill. An AI resource that nothing calls costs nothing.
Alien bills for deployment management. See [Pricing](/pricing) for Alien's rates.
Prices on this page are the providers' list prices as of October 2026: global-endpoint rates for Claude, and AWS us-east-1 for GPT-OSS. Official pricing: [Amazon Bedrock](https://aws.amazon.com/bedrock/pricing/), [Vertex AI](https://cloud.google.com/vertex-ai/generative-ai/pricing#claude-models), [Claude in Microsoft Foundry](https://platform.claude.com/docs/en/about-claude/pricing#claude-in-microsoft-foundry-pricing).
## Token pricing [#token-pricing]
Inference is billed per token, priced per model. Output costs several times more than input on every provider.
| Model | Clouds | Input | Output |
| ----------------- | --------------- | ----------------- | ------------------ |
| Claude Sonnet 4.5 | AWS, GCP, Azure | $3.00 per million | $15.00 per million |
| GPT-OSS 120B | AWS (us-east-1) | $0.15 per million | $0.60 per million |
Claude costs the same wherever it's served, because Anthropic sets the rate and the clouds resell it. Pinning Claude to a region or data zone adds 10% on every cloud ($3.30 and $16.50 per million for Claude Sonnet 4.5 on Bedrock and Vertex AI).
Model choice moves the bill far more than cloud or region: the two rows above are more than 20x apart for the same work.
## Standing charges [#standing-charges]
| Cloud | Standing charge |
| ----- | ------------------------------------------------------------------------------ |
| AWS | None. Bedrock is an account-level API with nothing provisioned. |
| GCP | None. Enabling the Vertex AI API is free. |
| Azure | None. The AI Foundry account and its deployments bill per token, not per hour. |
An AI resource that nothing calls costs nothing on all three clouds.
## Example: 5M input and 1M output tokens a month [#example-5m-input-and-1m-output-tokens-a-month]
A support assistant handling about 5,000 conversations a month, at roughly 1,000 input and 200 output tokens each.
| Model | Input | Output | Monthly |
| ----------------- | ------ | ------ | ---------- |
| Claude Sonnet 4.5 | $15.00 | $15.00 | **$30.00** |
| GPT-OSS 120B | $0.75 | $0.60 | **$1.35** |
Same traffic, same code, one field different in the request.
## Kubernetes, Machines, and Local [#kubernetes-machines-and-local]
No cloud charges. These use an external binding with your key, so you pay your provider directly at its published rates.
## Free tiers [#free-tiers]
* **AWS**: no free tier for Bedrock on-demand inference.
* **GCP**: new-account credits apply to Vertex AI. There is no always-free inference allowance.
* **Azure**: new-account credits apply to Foundry. There is no always-free inference allowance.
## Sources [#sources]
* [Amazon Bedrock pricing](https://aws.amazon.com/bedrock/pricing/)
* [Vertex AI pricing for Claude models](https://cloud.google.com/vertex-ai/generative-ai/pricing#claude-models)
* [Claude in Microsoft Foundry pricing](https://platform.claude.com/docs/en/about-claude/pricing#claude-in-microsoft-foundry-pricing)
* [Anthropic model pricing](https://platform.claude.com/docs/en/about-claude/pricing)
# ComputeCluster (/docs/infrastructure/compute-cluster)
A ComputeCluster is the set of machine pools in the customer's account that [Containers](/docs/infrastructure/container) and [Daemons](/docs/infrastructure/daemon) run on. Declare one to control the pools and their hardware; otherwise Alien adds a default.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const cluster = new alien.ComputeCluster("compute")
.pool("general", {
requirements: { cpu: 4, memory: "16Gi", architecture: "arm64" },
scale: { type: "autoscale", min: 2, max: 10 },
})
.build()
const api = new alien.Container("api")
.code({ type: "image", image: "ghcr.io/acme/api:1.4.0" })
.cluster("compute")
.pool("general")
.cpu(1)
.memory("2Gi")
.port(8080)
.permissions("execution")
.build()
export default new alien.Stack("acme")
.add(cluster, "frozen")
.add(api, "live")
.permissions({ profiles: { execution: {} } })
.build()
```
In this stack:
* `.permissions("execution")` names the Container's [permission profile](/docs/guide/permissions), the set of cloud permissions it runs with. `execution` grants nothing here, because the Container uses no other resource.
* `"live"` means Alien creates and updates the Container on every release. See [Frozen and live](/docs/guide/frozen-and-live).
| Platform | Provisions |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | One Auto Scaling group per pool, EC2 instance profile, launch template |
| Google Cloud | One managed instance group per pool, service account, instance template |
| Azure | One Virtual Machine Scale Set per pool, managed identity |
| Kubernetes | Nothing. Describes logical pools in the deployment namespace and applies architecture constraints to Pods. Node sizes, counts, and autoscaling stay with the cluster administrator. |
| Machines | Pools are labels. Each host chooses its pool with `alien-deploy join --capacity-group`. Alien doesn't add or remove hosts. |
| Local | A Docker network for local containers. No machines. |
Kubernetes rejects accelerators, nested virtualization, custom container CIDRs, provider instance types, and selected zones.
A ComputeCluster is always `"frozen"`. The customer's admin creates it during initial setup. Afterwards, during ongoing management, Alien manages only the machines inside it (see [Frozen and live](/docs/guide/frozen-and-live)).
On AWS, Google Cloud, and Azure, when a stack has Containers and no cluster, Alien adds one named `compute` with a `general` pool sized from the containers.
## Options [#options]
| Method | Type | Default | Notes |
| ---------------------------------- | ------------------------------------ | ----------------- | -------------------------------------------------------------------------------------------------------- |
| `.pool(id, config)` | `ComputePoolInput` | none | Repeatable. Each pool becomes its own machine group. See [Pools](#pools). |
| `.nodePermissions(profile, opts?)` | `PermissionProfile`, `{ platforms }` | none | Grants the machine identity access to named resources. Limit to platforms with `{ platforms: ["aws"] }`. |
| `.containerCidr(cidr)` | `string` | `"10.244.0.0/16"` | Range for container networking. Each machine gets a `/24`. |
`ComputeCluster` has no `.enabled()`.
### Pools [#pools]
| Field | Type | Default | Notes |
| ----------------------------------- | ---------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `requirements.cpu` | `number \| string` | required | vCPUs per machine. |
| `requirements.memory` | `string` | required | Memory per machine, for example `"16Gi"`. |
| `requirements.ephemeralStorage` | `string` | `"20Gi"` | Local disk per machine. |
| `requirements.architecture` | `"arm64" \| "x86_64"` | build architecture | |
| `requirements.nestedVirtualization` | `boolean` | `false` | Machines that can run VMs. |
| `requirements.accelerators` | `{ type, count }[]` | none | GPUs per machine, for example `{ type: "nvidia-l4", count: 1 }`. |
| `requirements.dataDisk` | `{ size }` | none | An extra empty block device each host must provide. Machines platform only. |
| `scale` | `{ type: "fixed", machines } \| { type: "autoscale", min, max }` | required | Each count is a number or a `{ min, max, default }` range the deployer (the person who sets up the deployment, usually the customer's admin) chooses within. |
| `dynamicContainers` | `boolean` | `false` | Lets containers created after initial setup use this pool. Only one pool can opt in. |
| `failureDomainSpread` | `number` | none | Number of zones to spread across, 1 to 255. |
You declare hardware requirements, not instance types. The deployer picks provider machine types in deployment settings.
## Use it in code [#use-it-in-code]
A ComputeCluster has no runtime binding. Workloads reach it by setting `.cluster(id)` and `.pool(name)`.
## Behavior and limits [#behavior-and-limits]
| Limit | Value |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Pools accepting dynamic containers | 1 |
| Failure domain spread | 1 to 255 |
| Container CIDR per machine | `/24` (254 container addresses) |
| `dataDisk` | Machines platform only. Other clouds and Kubernetes refuse it. |
| Cluster ID on Google Cloud | 18 characters. The machine service account is named `--sa`, and Google Cloud caps service account IDs at 30. |
| CPU reserved per machine | 0.5 vCPU for the node agent and system processes. Plan workloads against the rest. |
**Machine counts are the deployer's choice within your bounds.** A count given as `{ min, max, default }` lets the deployer pick a value in range during initial setup.
For the `general` pool Alien generates, the counts are a recommendation sized from container replicas. Initial setup offers up to 100 machines, and the deployer can go higher.
**Hardware is matched, not named.** Alien turns `requirements` into a provider machine type when the deployment is set up. The pool's `cpu`, `memory`, and `ephemeralStorage` are machine totals, not what's left for containers.
**Changes after initial setup.** `containerCidr` can't change once set.
A pool's machine type can change only to another catalog machine in the same cloud with the same CPU architecture, because the stack's images are built for one architecture. Alien rolls the existing machines over to the new type.
Change a running deployment's pools from the CLI:
```bash
alien deployments set-compute customer-1/customer-1 --pool general --machines 3
alien deployments set-compute customer-1/customer-1 --pool general --min 2 --max 6
alien deployments set-compute customer-1/customer-1 --pool general --machine m6i.large
```
The update replaces the deployment's whole pool map, so the CLI sends the other pools back as it read them. Container CPU and memory aren't sent and keep their values. Scaling a pool to 0 machines asks for confirmation; pass `--yes` in scripts.
**What Alien manages.** Initial setup creates the machine groups, identities, and network boundary. After that, with `compute-cluster/management`, Alien:
* changes machine counts within the declared bounds,
* rolls out new machine images,
* on AWS, changes the machine type within one architecture.
**Kubernetes uses existing nodes.** Pool CPU, memory, disk, machine counts, autoscaling, and failure-domain spread are the cluster administrator's. Alien enforces only architecture, through Pod scheduling, and warns that the rest isn't enforced.
## Permissions [#permissions]
| Set | Grants |
| ---------------------------- | --------------------------------------------------------------------------------- |
| `compute-cluster/provision` | Creating machine groups, launch templates, and identities. Used by initial setup. |
| `compute-cluster/management` | Rolling updates and scaling within declared bounds. |
| `compute-cluster/execute` | What the machines themselves need (image pull, logs, metadata). |
| `compute-cluster/heartbeat` | Health checks. |
## Next [#next]
* [Container](/docs/infrastructure/container)
* [Daemon](/docs/infrastructure/daemon)
* [Machines](/docs/infrastructure/platforms/machines)
# Choose compute (/docs/infrastructure/compute)
Each piece of code in a [stack](/docs/guide/stacks), called a **workload**, runs as a Worker, a Container, or a Daemon. Each workload uses one of them, and one stack can mix all three.
```ts title="alien.ts"
// Event-driven function that scales to zero
const processor = new alien.Worker("processor")
.code({ type: "source", src: "./processor", toolchain: { type: "typescript" } })
.permissions("processor")
.build()
// Long-running service with its own port
const ocr = new alien.Container("ocr")
.code({ type: "image", image: "ghcr.io/acme/ocr:2.4.0" })
.cpu(4)
.memory("16Gi")
.port(8080)
.permissions("ocr")
.build()
// One process on every machine in its pool
const collector = new alien.Daemon("collector")
.code({
type: "source",
src: "./collector",
toolchain: { type: "rust", binaryName: "collector" },
})
.permissions("collector")
.build()
```
| | AWS | Google Cloud | Azure | Kubernetes |
| ----------- | --------------------------- | -------------------------------- | ----------------------- | ---------- |
| `Worker` | Lambda | Cloud Run | Container Apps | Deployment |
| `Container` | EC2 machines Alien manages | Compute Engine VMs Alien manages | Azure VMs Alien manages | Pods |
| `Daemon` | One process per EC2 machine | One process per VM | One process per VM | DaemonSet |
## Decision guide [#decision-guide]
| You need | Use |
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| Code that reacts to uploads, queue messages, schedules, or commands, and scales to zero | [Worker](/docs/infrastructure/worker) |
| A service that stays up: an API server, a database, a model server, anything with its own port or state | [Container](/docs/infrastructure/container) |
| One agent per host: log or metrics collection, a sandbox runtime, anything that needs the host's filesystem or network | [Daemon](/docs/infrastructure/daemon) |
Start with a Worker. Move to a Container when the work outlives a request, needs a GPU, or ships as an image you don't build with Alien. Use a Daemon only when the code must run on every machine.
To run code you don't trust, such as a customer's script or code a model wrote, use a [Sandbox](/docs/infrastructure/sandbox). Your app creates isolated sandboxes at runtime and runs commands inside them.
## Worker [#worker]
A **Worker** runs your handler on the cloud's serverless runtime. It starts on demand, so it costs nothing while idle.
```ts title="alien.ts"
const processor = new alien.Worker("processor")
.code({ type: "source", src: "./processor", toolchain: { type: "typescript" } })
.memoryMb(1024)
.timeoutSeconds(120)
.trigger({ type: "storage", storage: documents.ref(), events: ["created"] })
.trigger({ type: "schedule", cron: "0 3 * * *" })
.link(documents)
.permissions("processor")
.build()
```
This Worker indexes each new file in the `documents` bucket and runs a nightly job. Workers also receive [commands](/docs/guide/commands) and HTTP requests on a [public endpoint](/docs/guide/networking). Defaults are `512` MB of memory and a `180` second timeout.
Tradeoffs:
* On AWS, Workers build for `linux-arm64` (Graviton) by default. A vendor image built only for x86 needs a Container instead.
* Each invocation is bounded by the timeout. Long jobs belong in a Container, or split across queue messages.
* Workers don't run on [Machines](/docs/infrastructure/platforms/machines) (on-prem and bare-metal hosts). Use a Container or Daemon there.
## Container [#container]
A **Container** is a long-running service with its own ports, replicas, and optional disk. Use it for an API server, a model server, or a database.
```ts title="alien.ts"
const ocr = new alien.Container("ocr")
.code({ type: "image", image: "ghcr.io/acme/ocr:2.4.0" })
.cpu(4)
.memory("16Gi")
.gpu({ type: "nvidia-t4", count: 1 })
.minReplicas(1)
.port(8080)
.link(documents)
.permissions("ocr")
.build()
```
`cpu` and `memory` are required. Other options add autoscaling (`.autoScale()`), persistent volumes with backups (`.persistentStorage()`), health checks, and public HTTP or TCP endpoints. Code is a prebuilt image or a source folder with a `docker`, `typescript`, `rust`, or `python` toolchain.
Tradeoffs:
* On AWS, Google Cloud, and Azure, Containers run on virtual machines in the customer's account. Machines cost money while they run, unlike an idle Worker.
* GPUs (`.gpu({ type, count })`) work on cloud machines that offer them. Machines (on-prem hosts) don't support GPUs today.
* On Machines, Containers must be stateless.
### Private images [#private-images]
A Container can run an image from your private registry, such as GitHub Container Registry, Amazon ECR, or Docker Hub. Customers never need credentials for your registry:
1. `alien release` runs `docker pull --platform linux/` for each target, with the Docker login of the machine that runs the release.
2. The release copies the image into itself.
3. Deployments pull it from your manager with their deployment token.
No registry credentials go into `alien.ts`, the templates, or the Helm values.
* Log in on the machine or CI job that runs `alien release`. ECR login tokens expire after 12 hours, so log in in the same CI job.
* The image must exist for every target architecture: `linux/arm64` on AWS, `linux/amd64` on Google Cloud and Azure, both on Kubernetes and Machines. Publish a multi-architecture image if you need more than one.
* Pin the image by digest (`ghcr.io/acme/api@sha256:...`). Alien copies whatever the tag points to when the release runs.
* If the pull fails, the release stops with `docker pull failed for image '...'` and Docker's output. Nothing is published.
* `alien release --registry-username` and `--registry-password` set the registry the release is pushed to. They are not used to pull your image.
Only Containers get this copy. A Worker or Daemon with `type: "image"` keeps the reference as written, and the cloud must pull it without credentials. For a private base image, build the Worker or Daemon with the `docker` toolchain from a Dockerfile that starts `FROM` your image.
## Daemon [#daemon]
A **Daemon** runs exactly one instance on each machine in its pool. Use it for host-level agents that read host files, watch the host network, or manage other processes on the host.
```ts title="alien.ts"
const collector = new alien.Daemon("collector")
.code({
type: "source",
src: "./collector",
toolchain: { type: "rust", binaryName: "collector" },
})
.runtime({ mounts: [{ source: "/var/log", target: "/host/var/log" }] })
.link(documents)
.permissions("collector")
.build()
```
`.runtime()` grants host access: mounts, host network mode, the host PID namespace, or privileged mode. Grant only what the agent needs; the customer's security team will read it.
A Daemon scales with the number of machines, not with traffic.
## Compute pools [#compute-pools]
Containers and Daemons run on a **ComputeCluster**: a set of machine pools in the customer's account. Alien creates a default cluster when a stack has Containers. Declare one yourself to define pools, for example a GPU pool that scales to zero next to a small fixed pool:
```ts title="alien.ts"
const compute = new alien.ComputeCluster("compute")
.pool("gpu", {
requirements: { cpu: 8, memory: "32Gi", accelerators: [{ type: "nvidia-t4", count: 1 }] },
scale: { type: "autoscale", min: 0, max: 4 },
})
.pool("general", {
requirements: { cpu: 2, memory: "8Gi", architecture: "arm64" },
scale: { type: "fixed", machines: 2 },
})
.build()
```
Place a workload with `.cluster("compute").pool("gpu")`. Pools state portable requirements. The exact instance type is a deployment setting, so each customer can pick sizes during initial setup.
A ComputeCluster is always [frozen](/docs/guide/frozen-and-live): the customer's admin creates it during initial setup, and Alien manages the machines inside it afterwards. On Kubernetes, pools describe placement on the cluster's existing nodes and create no machines.
## Where each type runs [#where-each-type-runs]
| | AWS, Google Cloud, Azure | Kubernetes | Machines | `alien dev` |
| --------- | ------------------------ | ---------- | -------------- | -------------- |
| Worker | Yes | Yes | No | Native process |
| Container | Yes | Yes | Stateless only | Docker |
| Daemon | Yes | Yes | Yes | Native process |
The full matrix for every resource type is in [Platforms](/docs/reference/platforms). Every builder option is on the resource pages: [Worker](/docs/infrastructure/worker), [Container](/docs/infrastructure/container), [Daemon](/docs/infrastructure/daemon), [ComputeCluster](/docs/infrastructure/compute-cluster).
## Next [#next]
* [Using resources](/docs/infrastructure/using-resources): give compute access to storage, KV, and queues
* [Frozen and live resources](/docs/guide/frozen-and-live): who owns each resource after initial setup
* [Networking and endpoints](/docs/guide/networking): expose a Worker or Container over HTTPS
# Container API reference (/docs/infrastructure/container/api)
Every option of the [Container](/docs/infrastructure/container) builder in `alien.ts`, the SDK clients that resolve a container's URLs, its outputs, and the CLI commands for its volumes.
## Builder [#builder]
`new alien.Container(id)` from `@alienplatform/core`. The id uses lowercase letters, digits, and hyphens.
| Method | Type | Default | Notes |
| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.code(code)` | `ContainerCode` | required | Image or source build. Same values as [Worker toolchains](/docs/infrastructure/worker/toolchains). |
| `.cpu(value)` | `number \| { min, desired } \| { min, max, default }` | required | vCPUs. A number sets min and desired. A `{ min, max, default }` range lets the deployer (the person who sets up the deployment) choose at deploy time. |
| `.memory(value)` | `string \| { min, desired } \| { min, max, default }` | required | `Ki`/`Mi`/`Gi`/`Ti` suffix, for example `"512Mi"`. Ranges work like `cpu`. |
| `.permissions(profile)` | `string` | required | [Permission profile](/docs/reference/permissions) name. |
| `.port(n)` / `.ports(ns)` | `number` / `number[]` | none | Internal ports, reachable inside the cluster and by resources that link to this container. |
| `.publicEndpoint(name, port, opts?)` | `"http" \| "tcp" \| PublicEndpointOptions` | `"http"` | Adds `port` to the ports if it's missing. See [Public endpoints](#public-endpoints). |
| `.tunnel(port)` | `number` | none | Makes an HTTP port reachable from your own servers through the outbound connection of the Alien Operator, the agent in the cluster that pulls releases, without opening inbound access to the environment. Kubernetes only. See [Tunnels](/docs/infrastructure/platforms/kubernetes#tunnels). |
| `.egress("internet")` | `"internet"` | full network | Allows only public addresses, the container's own replicas on declared TCP ports, and the credential proxy. AWS, Google Cloud, and Azure only. Requires `security.profile: "restricted"`. |
| `.replicas(n)` | `number` | none | Fixed count. Clears autoscaling. |
| `.minReplicas(n)` | `number` | none | Sets min and desired to `n`, and max to 10 × `n`. |
| `.maxReplicas(n)` | `number` | none | Sets max. Min and desired default to 1. |
| `.autoScale(cfg)` | `{ min, desired, max, targetCpuPercent?, targetMemoryPercent?, targetHttpInFlightPerReplica?, maxHttpP95LatencyMs? }` | none | CPU target defaults to 70%, memory to 80%. |
| `.stateful(on)` | `boolean` | `false` | Stable ordinals (`api-0`, `api-1`). |
| `.persistentStorage(size, opts?)` | `string`, [`PersistentStorageOptions`](#persistent-storage) | none | Sets `stateful(true)`. |
| `.ephemeralStorage(size)` | `string` | platform default | Scratch space. Not persisted; keep nothing there you need later. |
| `.gpu({ type, count })` | `{ type: string, count: number }` | none | `type` such as `"nvidia-a100"` or `"nvidia-t4"`. `count` 1 to 8. |
| `.healthCheck(cfg)` | [`HealthCheck`](#health-checks) | none | Used during rollout and refresh. |
| `.readinessProbe({ method, path })` | | none | Shorthand for `.healthCheck()` with timeout 1 and failure threshold 3. |
| `.command(args)` | `string[]` | image default | Overrides the image command. |
| `.stopGracePeriod(sec)` | `number` | backend default | 1 to 86,400 seconds. |
| `.environment(vars)` | `Record` | `{}` | Merges into the existing map. |
| `.link(resource)` | `Resource` | none | Repeatable. |
| `.commandsEnabled(on)` | `boolean` | `false` | Injects `ALIEN_COMMANDS_*`. Your app runs `createCommandReceiver()` itself. |
| `.cluster(id)` | `string` | default cluster | ComputeCluster id. |
| `.pool(name)` | `string` | [automatic](/docs/infrastructure/container/behavior#placement) | Capacity group within the cluster. |
| `.security(cfg)` | `{ profile: "restricted", runAsUser, runAsGroup, readOnlyRootFilesystem }` | none | Portable security profile and process identity. Refused on Local. |
| `.kubernetesSecretMount({ secretName, mountPath })` | | none | Mounts an existing Secret from the deployment namespace, read-only. Kubernetes only. |
| `.kubernetesLivenessProbe({ path, port })` / `.kubernetesReadinessProbe({ path, port })` | | none | Kubernetes HTTP probes. Kubernetes only. |
| `.enabled(input)` | boolean input | always | Creates the resource only when the input is true. |
Changing the id, cluster, stateful mode, ports, public endpoints, pool, or `persistentStorage.encryptionKey` replaces the resource.
### Public endpoints [#public-endpoints]
```ts
.publicEndpoint("web", 8080) // HTTP
.publicEndpoint("db", 5432, "tcp")
.publicEndpoint("app", 8080, { protocol: "http", hostLabel: "@", wildcardSubdomains: false })
```
| Option | Type | Notes |
| -------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `protocol` | `"http" \| "tcp"` | HTTP terminates TLS at the load balancer. TCP passes traffic through. TCP works on AWS, Google Cloud, Azure, and Local. |
| `hostLabel` | `string` | DNS label for the generated hostname. Defaults to the endpoint name. `"@"` uses the apex, and only one endpoint per container can. |
| `wildcardSubdomains` | `boolean` | Also routes wildcard subdomains to this endpoint. Not allowed on the apex endpoint. Default `false`. |
Endpoint names are lowercase DNS labels, up to 63 characters. All public endpoints on one container use the same backend port and the same protocol.
### Persistent storage [#persistent-storage]
| Option | Type | Default | Notes |
| --------------- | --------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------ |
| `mountPath` | `string` | `/data` | |
| `encryptionKey` | Key resource | provider key | Customer-managed [Key](/docs/infrastructure/key) in the same stack. Changing it replaces the resource. |
| `backups` | `false \| { intervalHours?, retentionDays? }` | on | `intervalHours`: 1, 2, 4, 6, 8, 12, or 24 (default 24). `retentionDays`: 1 to 365 (default 7). |
### Health checks [#health-checks]
| Field | Default | Notes |
| ------------------ | -------------- | ------- |
| `path` | `/health` | |
| `method` | `GET` | |
| `port` | container port | |
| `timeoutSeconds` | 1 | 1 to 5. |
| `failureThreshold` | 3 | |
## SDK [#sdk]
### TypeScript [#typescript]
```ts
import { container } from "@alienplatform/sdk"
const search = container("search")
```
| Method | Returns | Notes |
| ------------------- | --------------------------------- | ------------------------------------------------------------------------------ |
| `getInternalUrl()` | `Promise` | URL of the first port. |
| `getInternalUrls()` | `Promise>` | URL per port. |
| `getPublicUrl()` | `Promise` | URL of the first declared public endpoint. `null` when the container has none. |
### Python [#python]
```python
from alienplatform import container
search = container("search")
```
| Method | Returns |
| ----------------------- | ---------------- |
| `await internal_url()` | `str` |
| `await internal_urls()` | `dict[int, str]` |
| `await public_url()` | `str \| None` |
## Outputs [#outputs]
| Field | Notes |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `name` | Runtime container name. |
| `status` | `pending`, `running`, `stopped`, or `failing`. |
| `currentReplicas`, `desiredReplicas` | Replica counts. |
| `internalDns` | Internal service DNS name. |
| `publicEndpoints` | Public endpoints by name. |
| `replicas` | Per-replica status, with `ordinal` for stateful replicas. |
| `volumeBackups.state` | `active`, `disabled`, or `setupRequired`. With `setupRequired`, `volumeBackups.message` says what is missing. |
| `volumes[]` | `ordinal`, `volumeId`, `zone`, `lastSnapshotId`, `lastSnapshotAt`, and `lastRestore` for each replica's volume. |
## CLI [#cli]
| Command | Does |
| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| `alien deployments volumes ` | Lists container volumes, their latest snapshots, and restores. |
| `alien deployments restore-volume --resource --ordinal --snapshot ` | Restores one replica's volume from a snapshot. |
| `alien deployments cancel-volume-restore ` | Cancels a volume restore. |
`` is a deployment id or `/`. Each command accepts `--json`.
# Container behavior and limits (/docs/infrastructure/container/behavior)
How a [Container](/docs/infrastructure/container) is placed on machines, scaled, exposed, and backed up on each platform, and the limits Alien enforces.
## Placement [#placement]
On AWS, Google Cloud, and Azure, replicas run on the machines of a [ComputeCluster](/docs/infrastructure/compute-cluster). If the stack declares none, Alien creates one with a `general` pool.
When `.pool()` isn't set, Alien picks the pool in this order:
1. `stateful`, when the container has persistent storage.
2. `gpu`, when it requests a GPU.
3. `storage`, when ephemeral storage is over 200 Gi.
4. `general` otherwise.
A stateful replica is placed where its volume lives.
## Scaling [#scaling]
| Call | Min | Desired | Max |
| ----------------------- | --------- | ------------- | -------------------- |
| `.replicas(n)` | `n` | `n` | `n`, autoscaling off |
| `.minReplicas(n)` | `n` | `n` | 10 × `n` |
| `.maxReplicas(n)` alone | 1 | 1 | `n` |
| `.autoScale(cfg)` | `cfg.min` | `cfg.desired` | `cfg.max` |
Autoscaling targets 70% CPU and 80% memory unless you set them. It can also scale on in-flight HTTP requests per replica (`targetHttpInFlightPerReplica`) and on p95 latency (`maxHttpP95LatencyMs`).
## Public endpoints [#public-endpoints]
* All public endpoints on one container route to the same backend port.
* HTTP and TCP endpoints can't be mixed on one container.
* Only one endpoint can use the apex (`hostLabel: "@"`), and it can't also take wildcard subdomains.
* Endpoint names are lowercase DNS labels, up to 63 characters.
* TCP endpoints work on AWS, Google Cloud, Azure, and Local. Kubernetes and Machines refuse them.
| Platform | HTTP | TCP |
| ------------ | ------------------------------------------------------------------- | ------------------------ |
| AWS | Application Load Balancer | Network Load Balancer |
| Google Cloud | Network endpoint groups and a backend service behind an HTTPS proxy | Same, behind a TCP proxy |
| Azure | Application Gateway | Azure Load Balancer |
## Volumes [#volumes]
Each ordinal keeps its volume across restarts and replacement. Snapshots are crash-consistent and stay in the customer's account, in the volume's region. They keep being taken when the deployment can't reach Alien.
| Platform | Volume | Snapshots |
| ------------ | ------------------------------- | ------------------------------------------------------------------- |
| AWS | gp3 EBS | Amazon Data Lifecycle Manager |
| Google Cloud | Persistent Disk | Compute Engine snapshot schedule |
| Azure | Managed Disks | Azure Disk Backup |
| Kubernetes | PersistentVolumeClaim templates | None. Use the cluster's tooling, such as VolumeSnapshots or Velero. |
| Machines | Not supported | |
| Local | Bind mounts | None |
### Backup schedule [#backup-schedule]
`intervalHours` is 1, 2, 4, 6, 8, 12, or 24 (default 24). `retentionDays` is 1 to 365 (default 7). A volume holds at most 450 snapshots, so hourly backups can be kept for up to 18 days.
Turning backups off keeps existing snapshots until the container is deleted. When the container is deleted, Alien keeps one final snapshot of each volume, unless backups are off.
The `volumeBackups.state` output is `active`, `disabled`, or `setupRequired`. `setupRequired` means the deployment's management permissions predate volume backups. The container keeps running without a schedule, and the schedule is applied once the customer's admin reruns initial setup.
### Restore [#restore]
`alien deployments restore-volume` restores one replica's volume:
1. Alien snapshots the current volume, so the restore can be undone.
2. It stops that replica.
3. It creates a new volume from the snapshot, in the same zone.
4. It starts the replica on the new volume.
Other replicas keep running. The snapshot must belong to the same deployment and be no larger than the configured size. If a restore fails, retry the deployment or run `alien deployments cancel-volume-restore`.
## Platform checks [#platform-checks]
Alien refuses these at plan time:
| Declaration | Refused on |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `.egress("internet")` | Kubernetes, Machines, Local. Also refused without `security.profile: "restricted"`. |
| `.security()` | Local |
| `.kubernetesSecretMount()`, `.kubernetesLivenessProbe()`, `.kubernetesReadinessProbe()` | Every platform except Kubernetes |
| Stateful containers | Machines |
| TCP public endpoints | Kubernetes |
| Any public endpoint | Machines (`MACHINES_CONTAINER_PUBLIC_ENDPOINT`). Serve it from a [Daemon](/docs/infrastructure/daemon) with host networking instead. |
On AWS, a [Worker](/docs/infrastructure/worker) can link at most 4 Containers that have ports.
Linking a Container that has ports opens them to the linker. On AWS, Google Cloud, and Azure, the plan refuses these links with `CONTAINER_NETWORK_SETTINGS_UNSUPPORTED`:
* A Container or Daemon in a different compute cluster from the target.
* On Google Cloud and Azure, any linker other than a Container or Daemon, such as a Worker.
* On AWS, a Sandbox without `.egress("internet")`.
* On AWS, a target that a Worker or Sandbox links and that needs more than 32 load balancer targets: one per distinct port, plus one when it has public endpoints.
## Limits [#limits]
| Limit | Value |
| ---------------------------- | ---------------------------------------- |
| Public backend ports | 1 per container |
| Apex endpoints | 1 per container |
| Endpoint name | Lowercase DNS label, up to 63 characters |
| GPU count | 1 to 8 |
| Health check timeout | 1 to 5 seconds |
| Stop grace period | 1 to 86,400 seconds |
| Backup interval | 1, 2, 4, 6, 8, 12, or 24 hours |
| Backup retention | 1 to 365 days |
| Snapshots per volume | 450 |
| Load balancer targets on AWS | At most 32 per container |
## Changes that replace the resource [#changes-that-replace-the-resource]
Changing any of these after create replaces the container: id, cluster, stateful mode, ports, public endpoints, pool, and `persistentStorage.encryptionKey`.
# Container (/docs/infrastructure/container)
A Container is a long-lived service that runs from an image, with replicas, ports, and optional disks and GPUs. It fits API servers, queue consumers, databases, and model servers.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const api = new alien.Container("api")
.code({ type: "image", image: "ghcr.io/acme/api:1.4.0" })
.cpu(1)
.memory("1Gi")
.port(8080)
.publicEndpoint("web", 8080)
.healthCheck({ path: "/health" })
.minReplicas(2) // 2 replicas, autoscaling up to 20
.permissions("execution")
.build()
export default new alien.Stack("acme")
.add(api, "live")
.permissions({ profiles: { execution: {} } })
.build()
```
`.permissions("execution")` names the Container's [permission profile](/docs/guide/permissions), the set of cloud permissions it runs with. `execution` grants nothing here, because the Container uses no other resource.
| Platform | Runs on | Public endpoints | Persistent volumes |
| ------------ | ------------------------------------------------------------------------ | --------------------------------------------------------- | ------------------------------- |
| AWS | EC2 machines in a [ComputeCluster](/docs/infrastructure/compute-cluster) | ALB for HTTP, NLB for TCP | gp3 EBS |
| Google Cloud | Compute Engine machines in a ComputeCluster | Backend services behind an HTTPS or TCP proxy | Persistent Disk |
| Azure | Azure VMs in a ComputeCluster | Application Gateway for HTTP, Azure Load Balancer for TCP | Managed Disks |
| Kubernetes | Deployment, or StatefulSet when stateful | No TCP endpoints | PersistentVolumeClaim templates |
| Machines | Joined hosts | No TCP endpoints | Not supported |
| Local | Docker | HTTP and TCP | Bind mounts |
On AWS, Google Cloud, and Azure, Alien creates a ComputeCluster with a `general` pool if the stack doesn't declare one. See [Behavior and limits](/docs/infrastructure/container/behavior) for placement, scaling, and what each platform refuses.
A Container is always `"live"`: Alien creates and updates it on every release (see [Frozen and live](/docs/guide/frozen-and-live)). The image's entrypoint, or `.command()`, starts your process directly, with no runtime wrapper.
Images from a private registry are copied into the release, so customers never need your registry credentials (see [Private images](/docs/infrastructure/compute#private-images)). The [API reference](/docs/infrastructure/container/api#builder) lists every builder method.
## When to use another type [#when-to-use-another-type]
* [Worker](/docs/infrastructure/worker): requests and events that each finish within one invocation, at no cost while idle.
* [Daemon](/docs/infrastructure/daemon): exactly one process on every machine, such as a log collector.
* [Sandbox](/docs/infrastructure/sandbox): code you don't trust.
## Volumes and backups [#volumes-and-backups]
```ts title="alien.ts"
const key = new alien.Key("volume-key").build()
const db = new alien.Container("db")
.code({ type: "image", image: "postgres:17" })
.cpu(2)
.memory("8Gi")
.port(5432)
.persistentStorage("500Gi", {
mountPath: "/var/lib/postgresql/data",
encryptionKey: key,
backups: { intervalHours: 4, retentionDays: 14 },
})
.replicas(1)
.permissions("execution")
.build()
```
`.persistentStorage()` makes the container stateful. Each replica gets a stable ordinal (`db-0`, `db-1`) and keeps its own volume across restarts and replacement.
Backups are on by default: a snapshot every 24 hours, kept for 7 days. Snapshots stay in the customer's account, in the volume's region.
To restore one replica's volume:
```bash
alien deployments volumes
alien deployments restore-volume --resource db --ordinal 0 --snapshot
```
See [Volumes](/docs/infrastructure/container/behavior#volumes) for how restores work and which platforms take snapshots.
## Use it in code [#use-it-in-code]
Resolve a linked container's URLs:
```ts title="src/client.ts"
import { container } from "@alienplatform/sdk"
const search = container("search")
const internal = await search.getInternalUrl() // first port
const byPort = await search.getInternalUrls() // Record
const external = await search.getPublicUrl() // string | null
```
```python title="client.py"
from alienplatform import container
search = container("search")
internal = await search.internal_url()
by_port = await search.internal_urls()
external = await search.public_url()
```
A Container with `.commandsEnabled(true)` receives [commands](/docs/guide/commands) through a pull receiver it starts itself:
```ts title="src/index.ts"
import { createCommandReceiver } from "@alienplatform/commands"
const receiver = createCommandReceiver()
receiver.command("status", async () => ({ ready: true }))
await receiver.run()
```
## Permissions [#permissions]
| Set | Grants |
| --------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `container/provision` | Load balancers, volumes, and certificates. Used by initial setup or for live provisioning. |
| `container/management`, `container/heartbeat` | Inspecting and managing container infrastructure. |
| `container/volume-backups` | Lets AWS Data Lifecycle Manager snapshot and expire this stack's volumes. |
| `key/native-volume` | Encrypting volumes with a customer-managed Key. |
## Next [#next]
* [API reference](/docs/infrastructure/container/api)
* [Behavior and limits](/docs/infrastructure/container/behavior)
* [Pricing](/docs/infrastructure/container/pricing)
* [ComputeCluster](/docs/infrastructure/compute-cluster)
# Container pricing (/docs/infrastructure/container/pricing)
Alien doesn't charge per container. Everything a Container creates bills to the customer's cloud account at the provider's rates.
Prices on this page are the providers' list prices as of October 2026 in AWS us-east-1 and Azure East US. The customer pays them on their own cloud bill. Official pricing: [AWS](https://aws.amazon.com/pricing/), [Google Cloud](https://cloud.google.com/pricing/list), [Azure](https://azure.microsoft.com/en-us/pricing/).
## What bills [#what-bills]
| Item | When | Billed as |
| ------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Machines | Always, on AWS, Google Cloud, and Azure | The [ComputeCluster](/docs/infrastructure/compute-cluster)'s instances. Replicas share them, so a container's CPU and memory requests don't bill on their own. |
| Load balancer | When the container has public endpoints | Hourly, plus usage units |
| Volumes | Per replica with persistent storage | Provisioned GB per month |
| Snapshots | While backups are on, and the final snapshot after delete | Stored GB per month. Snapshots are incremental. |
| Data transfer | Traffic leaving the region or the cloud | The provider's egress rates |
On Kubernetes, Machines, and Local, Alien creates no cloud load balancers and takes no snapshots. A container uses the cluster or hosts you already run, plus the PersistentVolumes a stateful container claims on Kubernetes.
## List prices [#list-prices]
Prices change and differ by region. Verify current prices on the provider pages before you quote them.
| Cloud | Item | Price | Region | Source |
| ----- | ------------------------------- | ------------------------------------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | Application Load Balancer | $0.0225 per hour + $0.008 per LCU-hour | us-east-1 | [Elastic Load Balancing pricing](https://aws.amazon.com/elasticloadbalancing/pricing/) |
| AWS | Network Load Balancer | $0.0225 per hour + $0.006 per NLCU-hour | us-east-1 | [Elastic Load Balancing pricing](https://aws.amazon.com/elasticloadbalancing/pricing/) |
| AWS | gp3 EBS volume | $0.08 per GB-month, includes 3,000 IOPS and 125 MB/s | us-east-1 | [Amazon EBS pricing](https://aws.amazon.com/ebs/pricing/) |
| AWS | EBS snapshot, standard tier | $0.05 per GB-month | us-east-1 | [Amazon EBS pricing](https://aws.amazon.com/ebs/pricing/) |
| Azure | Application Gateway Standard_v2 | $0.20 per gateway-hour + $0.008 per capacity unit-hour | East US | [Azure Retail Prices API](https://prices.azure.com/api/retail/prices), [Application Gateway pricing](https://azure.microsoft.com/en-us/pricing/details/application-gateway/) |
Not listed here: Google Cloud load balancing and Persistent Disk, Azure Load Balancer, Azure Managed Disks, and Azure Disk Backup. Use the [Google Cloud pricing calculator](https://cloud.google.com/products/calculator) and the [Azure pricing calculator](https://azure.microsoft.com/en-us/pricing/calculator/).
# Daemon API reference (/docs/infrastructure/daemon/api)
Every option of the [Daemon](/docs/infrastructure/daemon) builder in `alien.ts`, the environment variables each instance receives, and its outputs.
## Builder [#builder]
`new alien.Daemon(id)` from `@alienplatform/core`. The id uses lowercase letters, digits, and hyphens.
| Method | Type | Default | Notes |
| ------------------------------------ | ----------------------------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------- |
| `.code(code)` | `DaemonCode` | required | Image or source build. Same values as [Worker toolchains](/docs/infrastructure/worker/toolchains). |
| `.cluster(id)` | `string` | none | ComputeCluster id. Required on AWS, Google Cloud, and Azure. On Kubernetes, with `.pool()`, picks the node pool. |
| `.pool(name)` | `string` | every pool | Runs only on machines or nodes of this pool. |
| `.cpu(value)` | `number \| { min, desired }` | `0.1` | vCPUs per instance. |
| `.memory(size)` | `string` | `"128Mi"` | Memory per instance. |
| `.permissions(profile)` | `string` | none | [Permission profile](/docs/reference/permissions). Without it, the daemon gets no cloud identity. |
| `.command(args)` | `string[]` | image default | Overrides the image command. |
| `.stopGracePeriod(sec)` | `number` | backend default | 1 to 86,400 seconds. |
| `.runtime(cfg)` | [`DaemonRuntime`](#runtime) | none | Host-level access. AWS, Google Cloud, and Azure. |
| `.publicEndpoint(name, port, opts?)` | `"http" \| { protocol: "http", hostLabel?, wildcardSubdomains? }` | `"http"` | HTTP only. Same options as [Container public endpoints](/docs/infrastructure/container/api#public-endpoints). |
| `.healthCheck(cfg)` | `{ path?, method?, port?, timeoutSeconds?, failureThreshold? }` | none | Same fields and defaults as [Container health checks](/docs/infrastructure/container/api#health-checks). |
| `.readinessProbe({ method, path })` | | none | Shorthand for `.healthCheck()` with timeout 1 and failure threshold 3. |
| `.environment(vars)` | `Record` | `{}` | Replaces the whole map. |
| `.link(resource)` | `Resource` | none | Repeatable. |
| `.commandsEnabled(on)` | `boolean` | `false` | Injects `ALIEN_COMMANDS_*`. Your app runs `createCommandReceiver()` itself. |
| `.enabled(input)` | boolean input | always | Creates the resource only when the input is true. |
`.environment()` on a Daemon replaces the map, unlike on a Container, where it merges. Call it once with every variable.
### Runtime [#runtime]
| Field | Type | Notes |
| -------------- | -------------------------------- | --------------------------------------------------------------------------------- |
| `privileged` | `boolean` | Elevated host capabilities. |
| `networkMode` | `"host" \| "appnet"` | `host` shares the machine's network. |
| `pidNamespace` | `"host" \| "private"` | `host` sees the machine's processes. |
| `user` | `string` | Numeric uid, or `uid:gid`. |
| `mounts` | `{ source, target, options? }[]` | Host paths mounted into the daemon. `source` and `target` must be absolute paths. |
## Environment [#environment]
On AWS, Google Cloud, and Azure, each instance also gets its place in the cluster. The address variables are set when the cluster's private network is up.
| Variable | Value |
| ------------------------------------- | --------------------------------------------------------------------------------- |
| `ALIEN_DAEMON_PRIVATE_ADDRESS` | This instance's private address. |
| `ALIEN_DAEMON_PRIVATE_ADDRESSES` | Private addresses of every machine in the cluster, comma-separated. |
| `ALIEN_DAEMON_POOL_PRIVATE_ADDRESSES` | Private addresses of the machines in this instance's pool, comma-separated. |
| `ALIEN_DAEMON_NODE_ORDINAL` | This machine's ordinal. You can set it yourself with `.environment()`. |
| `ALIEN_DAEMON_NODE_COUNT` | Number of machines in the cluster. You can set it yourself with `.environment()`. |
## Outputs [#outputs]
| Field | Notes |
| ----------------- | ------------------------------ |
| `daemonName` | Runtime daemon name. |
| `running` | Whether the daemon is running. |
| `publicEndpoints` | Public endpoints by name. |
# Daemon behavior and limits (/docs/infrastructure/daemon/behavior)
How a [Daemon](/docs/infrastructure/daemon) is placed and run on each platform, and the limits Alien enforces.
## Placement [#placement]
A Daemon runs one instance on every eligible machine. With `.pool()`, only machines of that pool are eligible.
On AWS, Google Cloud, and Azure, a Daemon must name its [ComputeCluster](/docs/infrastructure/compute-cluster) with `.cluster()`. A deployment without one fails. Unlike Containers, a Daemon doesn't fall back to a default cluster.
## Platforms [#platforms]
### AWS, Google Cloud, and Azure [#aws-google-cloud-and-azure]
One instance per machine of the cluster or pool. `.runtime()` is applied. Each instance gets the [cluster topology variables](/docs/infrastructure/daemon/api#environment).
### Kubernetes [#kubernetes]
A DaemonSet with `restartPolicy: Always`.
* `.cluster()` and `.pool()` select the node pool, and the DaemonSet gets that pool's node selector.
* Resource requests come from the `min` of `cpu` and `memory`, and limits from `desired`.
* `.stopGracePeriod()` sets the pod's termination grace period.
* `.runtime()` is not applied. Privileged mode, host namespaces, the user, and host mounts are ignored.
### Local [#local]
A supervised local process from the image. The health check runs every 5 seconds. `.runtime()` is not applied.
### Machines [#machines]
One instance per joined host.
## Public endpoints [#public-endpoints]
* HTTP only.
* All public endpoints route to the same backend port.
* Public endpoints can't change after create. Changing them replaces the resource.
* On Machines, a Daemon with public endpoints needs `runtime.networkMode: "host"` and must serve HTTPS on port 443 of the host itself. Without host networking, the build fails with `MACHINES_DAEMON_PUBLIC_ENDPOINT`.
## Runtime validation [#runtime-validation]
Alien refuses a `.runtime()` that breaks these rules:
| Field | Rule |
| ------------------------------------ | ------------------------- |
| `pidNamespace` | `host` or `private` |
| `networkMode` | `host` or `appnet` |
| `user` | Numeric uid, or `uid:gid` |
| `mounts[].source`, `mounts[].target` | Absolute paths |
## Limits [#limits]
| Limit | Value |
| ------------------------ | -------------------------------------- |
| Instances | 1 per eligible machine |
| Public endpoint protocol | HTTP |
| Public backend ports | 1 |
| Stop grace period | 1 to 86,400 seconds |
| Health check timeout | 1 to 5 seconds |
| Id | Lowercase letters, digits, and hyphens |
# Daemon (/docs/infrastructure/daemon)
A Daemon is a long-lived process that runs once on every eligible machine or Kubernetes node. It fits work that belongs to the machine: a log or metrics collector, a node agent, a per-host connector, or a loader that supervises a native process.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const cluster = new alien.ComputeCluster("compute")
.pool("general", {
requirements: { cpu: 4, memory: "16Gi" },
scale: { type: "autoscale", min: 2, max: 10 },
})
.build()
const collector = new alien.Daemon("collector")
.code({ type: "image", image: "ghcr.io/acme/collector:2.1.0" })
.cluster("compute")
.cpu(0.25)
.memory("256Mi")
.commandsEnabled(true)
.permissions("execution")
.build()
export default new alien.Stack("acme")
.add(cluster, "frozen")
.add(collector, "live")
.permissions({ profiles: { execution: {} } })
.build()
```
`.permissions("execution")` names the Daemon's [permission profile](/docs/guide/permissions), the set of cloud permissions it runs with. `execution` grants nothing here, because the Daemon uses no other resource. The ComputeCluster is `"frozen"`: the customer's admin creates it during [initial setup](/docs/guide/frozen-and-live), and Alien manages the machines inside it.
| Platform | Runs as |
| ------------ | ---------------------------------------------------------------------------------------- |
| AWS | One instance per EC2 machine in a [ComputeCluster](/docs/infrastructure/compute-cluster) |
| Google Cloud | One instance per Compute Engine machine in a ComputeCluster |
| Azure | One instance per VM in a ComputeCluster |
| Kubernetes | DaemonSet |
| Machines | One instance per joined host |
| Local | Supervised local process from the image |
On AWS, Google Cloud, and Azure, a Daemon must name its cluster with `.cluster()`. See [Behavior and limits](/docs/infrastructure/daemon/behavior) for how each platform runs it.
A Daemon is always `"live"`: Alien creates and updates it on every release (see [Frozen and live](/docs/guide/frozen-and-live)). It runs your process directly.
* Without `.cpu()` and `.memory()`, each instance gets 0.1 vCPU and `128Mi`.
* Without `.permissions()`, it gets no cloud identity.
* The [API reference](/docs/infrastructure/daemon/api#builder) lists every builder method.
Use a [Container](/docs/infrastructure/container) instead when you want a number of replicas that scales with load.
## Commands [#commands]
With `.commandsEnabled(true)`, the process starts a pull receiver, so your backend can call it:
```ts title="src/index.ts"
import { createCommandReceiver } from "@alienplatform/commands"
const receiver = createCommandReceiver()
receiver.command("flush", async () => ({ flushed: true }))
await receiver.run()
```
The receiver leases [commands](/docs/guide/commands) addressed to this daemon over outbound HTTPS, so the host needs no inbound access.
## Host access [#host-access]
```ts title="alien.ts"
const loader = new alien.Daemon("loader")
.code({ type: "image", image: "ghcr.io/acme/loader:1.0.0" })
.cluster("compute")
.runtime({
privileged: true,
networkMode: "host",
pidNamespace: "host",
user: "0:0",
mounts: [{ source: "/var/run", target: "/host/var/run" }],
})
.permissions("execution")
.build()
```
Use `.runtime()` only for daemons that need the host. It applies on AWS, Google Cloud, and Azure. Kubernetes and Local don't apply it today.
## Use it in code [#use-it-in-code]
Linked resources are available through the same SDK functions as other workloads, for example `storage("files")`. To receive commands, start a pull receiver as shown in [Commands](#commands).
## Permissions [#permissions]
| Set | Grants |
| --------------------------------------- | --------------------------------------------------------------------------- |
| `daemon/provision` | Public endpoint infrastructure. Used by initial setup or live provisioning. |
| `daemon/management`, `daemon/heartbeat` | Inspecting and managing the daemon. |
| `compute-cluster/execute` | Runtime access the machines need. Granted with the cluster. |
## Next [#next]
* [API reference](/docs/infrastructure/daemon/api)
* [Behavior and limits](/docs/infrastructure/daemon/behavior)
* [Pricing](/docs/infrastructure/daemon/pricing)
* [ComputeCluster](/docs/infrastructure/compute-cluster)
# Daemon pricing (/docs/infrastructure/daemon/pricing)
Alien doesn't charge per daemon. Everything a Daemon uses bills to the customer's cloud account at the provider's rates.
Prices on this page are the providers' list prices as of October 2026 in AWS us-east-1 and Azure East US. The customer pays them on their own cloud bill. Official pricing: [AWS](https://aws.amazon.com/pricing/), [Google Cloud](https://cloud.google.com/pricing/list), [Azure](https://azure.microsoft.com/en-us/pricing/).
## What bills [#what-bills]
| Item | When | Billed as |
| ------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Machines | Always, on AWS, Google Cloud, and Azure | The [ComputeCluster](/docs/infrastructure/compute-cluster)'s instances. A daemon's CPU and memory come out of every machine it runs on, which leaves less room for Containers. |
| Load balancer | When the daemon has public endpoints | Hourly, plus usage units |
| Data transfer | Traffic leaving the region or the cloud | The provider's egress rates |
A Daemon has no volumes and takes no snapshots. On Kubernetes, Machines, and Local, it uses the nodes or hosts you already run.
Because a Daemon runs on every eligible machine, its share of the cluster grows with the cluster. Size `.cpu()` and `.memory()` per instance, and use `.pool()` to keep it off machines that don't need it.
## List prices [#list-prices]
Prices change and differ by region. Verify current prices on the provider pages before you quote them.
| Cloud | Item | Price | Region | Source |
| ----- | ------------------------------- | ------------------------------------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | Application Load Balancer | $0.0225 per hour + $0.008 per LCU-hour | us-east-1 | [Elastic Load Balancing pricing](https://aws.amazon.com/elasticloadbalancing/pricing/) |
| Azure | Application Gateway Standard_v2 | $0.20 per gateway-hour + $0.008 per capacity unit-hour | East US | [Azure Retail Prices API](https://prices.azure.com/api/retail/prices), [Application Gateway pricing](https://azure.microsoft.com/en-us/pricing/details/application-gateway/) |
Not listed here: Google Cloud load balancing. Use the [Google Cloud pricing calculator](https://cloud.google.com/products/calculator). Machine prices are the provider's instance prices for the cluster's machine types.
# Email API reference (/docs/infrastructure/email/api)
The Email API is the binding, the permission sets, and the events your app uses with an [Email](/docs/infrastructure/email) resource. The **binding** is the connection details Alien injects into each workload [linked](/docs/infrastructure/using-resources#link) to the resource: here, which SES infrastructure to use.
Your app talks to SES directly with the AWS SDK (`@aws-sdk/client-sesv2` in TypeScript, or any AWS SDK).
There is no Alien send API. Every backend is SES, so a wrapper would add a layer without adding portability.
## The binding [#the-binding]
Linking an Email resource injects `ALIEN__BINDING`: the resource id uppercased, with hyphens replaced by underscores.
```ts
const binding = JSON.parse(process.env.ALIEN_MAILER_BINDING!)
```
| Field | Type | Notes |
| ------------------ | ------------------ | ------------------------------------------------------------------------------ |
| `service` | `"ses"` | |
| `region` | `string` | AWS region of the SES resources. |
| `configurationSet` | `string` | Pass as `ConfigurationSetName` on every send so events reach the linked queue. |
| `eventTopicArn` | `string`, optional | The SNS topic for events. Present only with `.events()`. |
The binding has no domain list: identities come and go at runtime, so a list fixed at initial setup would go stale. List the current identities with `ses:ListEmailIdentities`, granted by `email/manage-identities`.
## Permission sets [#permission-sets]
Grant these through [permission profiles](/docs/reference/permissions):
```ts
.permissions({
profiles: {
execution: { mailer: ["email/send", "email/manage-identities"] },
},
})
```
| Set | AWS actions | Scope |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `email/send` | `ses:SendEmail`, `ses:SendRawEmail` | Every identity, and this resource's configuration set |
| `email/manage-identities` | `ses:CreateEmailIdentity`, `ses:GetEmailIdentity`, `ses:DeleteEmailIdentity`, `ses:PutEmailIdentityMailFromAttributes`, `ses:PutEmailIdentityDkimAttributes`, `ses:PutEmailIdentityFeedbackAttributes`, `ses:PutEmailIdentityConfigurationSetAttributes`, `ses:ListEmailIdentities` | Every identity in the account and region |
| `email/management` | `ses:GetEmailIdentity`, `ses:GetConfigurationSet`, `ses:GetConfigurationSetEventDestinations`, `ses:DescribeReceiptRuleSet`, `ses:DescribeReceiptRule`, `ses:DescribeActiveReceiptRuleSet` | Read-only |
| `email/heartbeat` | `ses:DescribeActiveReceiptRuleSet` | Read-only |
| `email/provision` | Creating and deleting identities, the configuration set, event destinations, and receipt rules | Used at initial setup, not at runtime |
SES identities are named after mail domains, which aren't known at initial setup. So identity permissions cover every identity in the account and region, while configuration set permissions stay scoped to the stack.
`ses:ListEmailIdentities` and the receipt-rule reads don't support resource-level scoping in AWS IAM.
## Sending [#sending]
```ts
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"
const binding = JSON.parse(process.env.ALIEN_MAILER_BINDING!)
const ses = new SESv2Client({ region: binding.region })
await ses.send(new SendEmailCommand({
ConfigurationSetName: binding.configurationSet,
FromEmailAddress: "hello@mail.example.com",
Destination: { ToAddresses: ["someone@example.org"] },
Content: {
Simple: {
Subject: { Data: "Hello" },
Body: { Text: { Data: "Sent through SES in the customer's account." } },
},
},
}))
```
## Managing domains at runtime [#managing-domains-at-runtime]
With `email/manage-identities`, your app owns the domain lifecycle:
1. Create an identity when a customer adds a domain.
2. Hand them the DKIM records.
3. Poll verification.
4. Delete the identity when they remove the domain.
```ts
import {
SESv2Client,
CreateEmailIdentityCommand,
GetEmailIdentityCommand,
DeleteEmailIdentityCommand,
} from "@aws-sdk/client-sesv2"
const ses = new SESv2Client({ region: binding.region })
// Customer adds a domain
const created = await ses.send(new CreateEmailIdentityCommand({
EmailIdentity: "mail.customer-1.com",
ConfigurationSetName: binding.configurationSet,
}))
// created.DkimAttributes.Tokens holds three tokens. The customer creates
// ._domainkey.mail.customer-1.com CNAME .dkim.amazonses.com
// Poll verification
const identity = await ses.send(new GetEmailIdentityCommand({
EmailIdentity: "mail.customer-1.com",
}))
// identity.VerifiedForSendingStatus
// Customer removes the domain
await ses.send(new DeleteEmailIdentityCommand({
EmailIdentity: "mail.customer-1.com",
}))
```
Identities created this way are app data. The deployment doesn't track them, and they survive stack deletion. Deleting them is up to your app.
## Inbound mail [#inbound-mail]
With `.inbound(storage)`, each incoming message is written to the linked bucket as a raw MIME object. Read the objects with the [Storage](/docs/infrastructure/storage) binding and parse them with a MIME library.
The receipt rule is a catch-all. Mail for any identity the account receives mail for lands in the bucket, including identities your app created at runtime.
With `.events(queue)` as well, each received message also produces an SES `Received` notification on the queue. It carries the envelope recipients, the spam and virus verdicts, and the object key of the message in the bucket.
Use the notification to find new mail instead of listing the bucket. The envelope recipients can't be recovered reliably from message headers.
## Sending events [#sending-events]
With `.events(queue)`, SES publishes `Send`, `Delivery`, `Bounce`, `Complaint`, `DeliveryDelay`, and `Reject` events for mail sent through the configuration set.
Events go through an SNS topic to the queue with raw message delivery, so each queue message body is the SES event JSON. Consume them with a Worker [queue trigger](/docs/infrastructure/worker/events-and-triggers).
## Outputs [#outputs]
| Field | Notes |
| ------------------ | -------------------------------------------------------------------------------------- |
| `domains` | Per seed domain, `dkimTokens`: three `{ name, value }` CNAME records to create in DNS. |
| `configurationSet` | The configuration set name. |
| `ruleSetName` | The inbound receipt rule set. Present only with `.inbound()`. |
Verification status isn't an output, since SES verifies asynchronously after the records exist. Read it with `GetEmailIdentity`.
# Email behavior and limits (/docs/infrastructure/email/behavior)
[Email](/docs/infrastructure/email) sends from the customer's own domains through SES in their AWS account, and never replaces mail routing they already have. It's available only on AWS.
## Guarantees [#guarantees]
**The customer's domains and reputation.** Mail is sent through SES in the customer's AWS account, from domains they own. Sending reputation, quotas, and deliverability belong to that account: there is no shared sending pool.
**Easy DKIM on every seed domain.** Each seed identity is created with Easy DKIM signing and the configuration set attached. SES verifies the domain once the three CNAME records exist in DNS.
**Runtime identities survive the stack.** Identities created through `email/manage-identities` are app data. The deployment doesn't track them, and deleting the stack doesn't remove them.
Seed domains are different: removing one from `domains` deletes its identity and its DKIM verification state.
**Catch-all inbound with scanning.** The receipt rule has no recipient filter, so mail for any identity the account receives mail for lands in the bucket without a stack change. Spam and virus scanning are on.
**Existing mail routing is never replaced.** Initial setup refuses to activate its receipt rule set if another rule set is already active in the account and region, so your deployment can't break mail the customer already receives through SES.
**Complete event coverage.** With `.events()`, SES publishes send, delivery, bounce, complaint, delivery-delay, and reject events for all mail sent through the configuration set.
## Limits [#limits]
| Limit | Value | Notes |
| -------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Resource id | `[A-Za-z0-9-_]`, up to 64 characters | Immutable after create. |
| Seed domains | Unique | Listing a domain twice fails at initial setup. |
| Email resources with inbound | 1 per stack, and 1 per AWS account and region | SES allows one active receipt rule set per account and region. |
| Email resources per events queue | 1 | A queue has one effective policy, so two Email resources on one queue would overwrite each other's policy. |
| Inbound regions | SES receiving regions only | SES receives mail in a subset of AWS regions. Elsewhere, initial setup fails. |
| Linked Storage and Queue | Must be frozen | Initial setup wires them, and a live resource doesn't exist yet at that point. |
| New AWS accounts | SES sandbox | Verified recipients only, low quotas. Production access is a request to AWS. |
## Update semantics [#update-semantics]
Email is frozen, so changes take effect only when the customer's admin runs initial setup again. Ongoing management doesn't apply them.
* **`domains`.** Adding a domain creates a new identity. Removing one deletes its identity and DKIM verification state. The list may be empty.
* **`.inbound()` and `.events()`.** Can be added, removed, or pointed at another resource. Removing them tears down the receipt rule set or the event destination.
* **`id`.** Immutable.
## Inbound activation [#inbound-activation]
SES allows one active receipt rule set per account and region, and CloudFormation has no resource that activates one. Initial setup adds a small function that runs when the stack is created, updated, and deleted:
* **Create and update.** If no rule set is active, it activates this stack's rule set. If another rule set is active, initial setup fails with an error naming it, and existing routing isn't modified.
To recover, pick an unused region, or deactivate the existing rule set on purpose and retry.
* **Delete.** If this stack's rule set is still the active one, it deactivates it.
The rule set name is in the resource outputs as `ruleSetName`. Every 5 minutes, Alien checks that it's still the active rule set.
If someone changed SES routing in the account, the resource reports unhealthy, instead of looking healthy while mail no longer reaches the bucket.
## What initial setup provisions [#what-initial-setup-provisions]
* One `AWS::SES::ConfigurationSet`, named after the stack and resource id.
* Per seed domain, an `AWS::SES::EmailIdentity` with Easy DKIM and the configuration set attached.
* With `.events()`: a configuration set event destination publishing to an SNS topic, subscribed to the linked SQS queue with raw delivery, plus the queue policy that lets the topic send.
* With `.inbound()`: a receipt rule set and a catch-all rule whose S3 action writes to the linked bucket, plus the bucket policy statement that lets SES write. With `.events()` too, the S3 action also publishes the receipt notification to the topic.
* With `.inbound()`: the Python function that activates the rule set.
* IAM policies attaching the granted `email/*` permission sets to the workload roles.
## Design decisions [#design-decisions]
**Infrastructure, not domain lifecycle.** The resource owns the configuration set and the event and inbound wiring. Email products usually create identities through the API when a customer adds a domain, so the domain lifecycle belongs to the app.
Seed `domains` are for domains you need from the first deployment, and for products with a fixed set of domains.
**No domain list in the binding.** Identities come and go at runtime, so a list fixed at initial setup would go stale.
**No Alien send API.** Every backend is SES. Wrapping the SDK would add a layer without adding portability.
**Frozen only.** Email holds durable routing state: identities, DKIM verification, and receipt rules. Only initial setup creates and changes it.
# Email (/docs/infrastructure/email)
Email is a resource for sending and receiving mail on your customers' own domains, with their sending reputation, through Amazon SES in their AWS account. It fits transactional mail, notifications, or an email-first product where each customer brings a domain.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const mailbox = new alien.Storage("mailbox").build()
const mailEvents = new alien.Queue("mail-events").build()
// Domains are added at runtime through email/manage-identities.
const mailer = new alien.Email("mailer")
.inbound(mailbox)
.events(mailEvents)
.build()
const api = new alien.Worker("api")
.code({ type: "source", src: "./api", toolchain: { type: "typescript" } })
.link(mailer)
.permissions("execution")
.build()
export default new alien.Stack("acme")
.platforms(["aws"])
.add(mailbox, "frozen")
.add(mailEvents, "frozen")
.add(mailer, "frozen")
.add(api, "live")
.permissions({
profiles: { execution: { mailer: ["email/send", "email/manage-identities"] } },
})
.build()
```
In this stack:
* `.link(mailer)` connects the Email resource to the `api` Worker, the **workload** (the running code) that uses it. Alien injects the Email resource's connection details, its **binding**, into the Worker, which the AWS SDK uses directly.
* The `execution` [permission profile](/docs/guide/permissions), the named set of cloud permissions a workload runs with, grants `email/send` and `email/manage-identities`.
* `"frozen"` means the customer's admin creates the Email resource during initial setup. `"live"` resources, like the Worker, are created and updated by Alien on every release. See [Frozen and live](/docs/guide/frozen-and-live).
The `api` Worker sends with the AWS SDK and the configuration set from its binding. There is no Alien send API:
```ts title="api/src/mail.ts"
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"
const { region, configurationSet } = JSON.parse(process.env.ALIEN_MAILER_BINDING!)
await new SESv2Client({ region }).send(new SendEmailCommand({
ConfigurationSetName: configurationSet,
FromEmailAddress: "hello@mail.example.com",
Destination: { ToAddresses: ["someone@example.org"] },
Content: { Simple: { Subject: { Data: "Hello" }, Body: { Text: { Data: "Hi there." } } } },
}))
```
A product with a fixed set of domains can create their identities at initial setup:
```ts
const mailer = new alien.Email("mailer").domains(["mail.example.com"]).build()
```
## Platforms [#platforms]
`mailer` becomes SES infrastructure in the customer's AWS account: a configuration set, DKIM-verified domain identities, and optional inbound and event wiring.
| Platform | Backing service |
| --------------------------------------- | --------------- |
| AWS | Amazon SES |
| GCP, Azure, Kubernetes, Machines, Local | Not available |
## Frozen only [#frozen-only]
Email is always `"frozen"`: the customer's admin creates it during initial setup. See [Frozen and live](/docs/guide/frozen-and-live).
The linked Storage and Queue must be frozen too, since initial setup wires them together.
## Domains [#domains]
The resource owns the email infrastructure: the configuration set, the event wiring, and the inbound wiring. It doesn't own the domain lifecycle.
* **Domains added at runtime:** your app manages SES identities through the `email/manage-identities` permission set. See [Customer-added domains](#customer-added-domains).
* **A fixed set of domains:** create their identities during initial setup with `.domains()`.
```ts
const mailer = new alien.Email("mailer").domains(["mail.example.com"]).build()
```
## Options [#options]
| Method | Type | Default | Notes |
| --------------------- | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------- |
| `new alien.Email(id)` | `string` | | `[A-Za-z0-9-_]`, up to 64 characters. Immutable. |
| `.domains(list)` | `string[]` | `[]` | Seed domains created at initial setup, one SES identity each with Easy DKIM. Replaces the list. |
| `.domain(d)` | `string` | | Adds one seed domain. |
| `.inbound(storage)` | Storage resource | none | Writes raw incoming MIME messages to the bucket. |
| `.events(queue)` | Queue resource | none | Delivers sending events to the queue. With `.inbound()`, the queue also receives SES `Received` notifications. |
| `.enabled(input)` | boolean stack input | always | |
`domains` may be empty. A configuration-set-only resource is valid.
## Domain verification [#domain-verification]
Each seed domain gets an SES identity with Easy DKIM. The resource outputs carry three DKIM CNAME records per seed domain. Create them in the domain's DNS, and SES verifies the domain once they exist.
For identities your app creates at runtime, read the DKIM tokens from the `CreateEmailIdentity` response and hand them to the customer.
## Use it in code [#use-it-in-code]
Send with the AWS SDK and the configuration set from the binding, as the `api` Worker does above. There is no Alien send API.
The workload's credentials come from its permission profile, so there are no keys to configure. See the [API reference](/docs/infrastructure/email/api) for the binding, runtime domain management, inbound mail, and events.
## Common patterns [#common-patterns]
### Customer-added domains [#customer-added-domains]
No stack change is needed when a customer adds a domain in your product:
1. Your app creates the SES identity.
2. Your product shows the customer the three DKIM records.
3. Your app polls verification.
4. When the customer removes the domain, your app deletes the identity.
### Inbound processing [#inbound-processing]
Link a bucket with `.inbound()` and a queue with `.events()`. Each received message arrives in the queue as a `Received` notification with its envelope recipients and the object key of the raw message in the bucket.
### Bounce handling [#bounce-handling]
Consume `Bounce` and `Complaint` events from the events queue and suppress those addresses in your app.
## Permissions [#permissions]
| Set | Grants |
| ------------------------- | -------------------------------------------------------------------------------- |
| `email/send` | `ses:SendEmail` and `ses:SendRawEmail`. |
| `email/manage-identities` | Creating, reading, listing, configuring, and deleting SES identities at runtime. |
| `email/management` | Reading identity status, the configuration set, and receipt rules. |
| `email/heartbeat` | Checking that the inbound receipt rule set is still active. |
| `email/provision` | Creating the SES resources at initial setup. |
## Next [#next]
* [API reference](/docs/infrastructure/email/api)
* [Behavior and limits](/docs/infrastructure/email/behavior)
* [Pricing](/docs/infrastructure/email/pricing)
* [Storage](/docs/infrastructure/storage)
* [Queue](/docs/infrastructure/queue)
# Email pricing (/docs/infrastructure/email/pricing)
Email costs about $0.10 per 1,000 messages, paid by the customer on their own AWS bill. An idle Email resource costs nothing. Alien bills for deployment management: see [Pricing](/pricing) for Alien's rates.
Prices are AWS list prices as of October 2026 in us-east-1. Official pricing: [Amazon SES pricing](https://aws.amazon.com/ses/pricing/).
## SES pricing [#ses-pricing]
SES bills per message, not per hour. The rates below are SES's pay-as-you-go (à la carte) rates.
| Component | Price |
| -------------------- | ------------------------------------ |
| Outbound email | $0.10 per 1,000 emails |
| Outbound attachments | $0.12 per GB |
| Inbound email | $0.10 per 1,000 emails |
| Inbound mail chunks | $0.09 per 1,000 chunks (256 KB each) |
SES also sells plans with a higher per-message rate. Since July 21, 2026, an AWS account with no SES usage since June 1, 2025 starts on the Essentials plan: $0.16 per 1,000 outbound emails for the first 10 million a month.
The account can switch to à la carte at any time.
## Example: 100,000 emails a month [#example-100000-emails-a-month]
| Item | Cost |
| ---------------------------- | ---------------------------------------------------------- |
| 100,000 outbound emails | $10.00 |
| Attachments | $0.12 per GB sent |
| **Total before attachments** | **$10.00** (à la carte), **$16.00** on the Essentials plan |
## Linked resources [#linked-resources]
Linked resources bill separately at their own rates, and both are small at typical mail volumes:
* Inbound mail lands in [Storage](/docs/infrastructure/storage) (S3).
* Events flow through an SNS topic to a [Queue](/docs/infrastructure/queue) (SQS). SNS-to-SQS delivery is free.
## Free tier [#free-tier]
SES has no separate free allowance. AWS accounts created since July 15, 2025 get up to $200 in AWS Free Tier credits, usable within 12 months, which apply to SES.
## Sources [#sources]
* [Amazon SES pricing](https://aws.amazon.com/ses/pricing/)
# Infrastructure (/docs/infrastructure)
## Compute
Where your code runs.
| Resource | What it is | AWS | Google Cloud | Azure | Kubernetes | Linux servers |
|---|---|---|---|---|---|---|
| [Worker](/docs/infrastructure/worker) | Serverless functions that run on requests, events, and schedules. | Lambda | Cloud Run | Container Apps | Deployment and Service | Not supported |
| [Container](/docs/infrastructure/container) | Long-running services. | EC2 machines | Compute Engine machines | Azure VMs | Deployment, or StatefulSet when stateful | Containers on your hosts (stateless) |
| [Daemon](/docs/infrastructure/daemon) | One process on every machine or node. | One per EC2 machine | One per Compute Engine machine | One per VM | DaemonSet | One per host |
| [ComputeCluster](/docs/infrastructure/compute-cluster) | The machine pools containers and daemons run on. | Auto Scaling group | Managed instance group | VM Scale Set | Logical pools on existing nodes | Host labels |
| [Sandbox](/docs/infrastructure/sandbox) | Isolated environments for untrusted code. | Lambda MicroVMs | Agent Platform sandboxes | Container Apps sandboxes | Pods with a sandboxed RuntimeClass | Not supported |
## Data
Where your code keeps state, in the customer’s account.
| Resource | What it is | AWS | Google Cloud | Azure | Kubernetes | Linux servers |
|---|---|---|---|---|---|---|
| [Storage](/docs/infrastructure/storage) | Files and objects. | S3 | Cloud Storage | Blob Storage | An existing service (external binding) | An existing service (external binding) |
| [Kv](/docs/infrastructure/kv) | Small records and indexes. | DynamoDB | Firestore | Table Storage | An existing service (external binding) | An existing service (external binding) |
| [Queue](/docs/infrastructure/queue) | Work items between components. | SQS | Pub/Sub | Service Bus | An existing service (external binding) | An existing service (external binding) |
| [Postgres](/docs/infrastructure/postgres) | Managed PostgreSQL. | Aurora Serverless v2 | Cloud SQL | Flexible Server | An existing service (external binding) | An existing service (external binding) |
## Secrets and keys
Credentials your code reads, and keys the customer controls.
| Resource | What it is | AWS | Google Cloud | Azure | Kubernetes | Linux servers |
|---|---|---|---|---|---|---|
| [Vault](/docs/infrastructure/vault) | Secrets your code reads at runtime. | SSM Parameter Store | Secret Manager | Key Vault | Kubernetes Secrets | An existing service (external binding) |
| [Key](/docs/infrastructure/key) | An encryption key the customer owns and can revoke. | KMS | Cloud KMS | Key Vault keys | Only on a cloud base platform | Not supported |
## AI and email
Services in the customer’s own account, with no API keys.
| Resource | What it is | AWS | Google Cloud | Azure | Kubernetes | Linux servers |
|---|---|---|---|---|---|---|
| [AI](/docs/infrastructure/ai) | Models in the customer’s account. | Amazon Bedrock | Vertex AI | Azure AI Foundry | An existing AI endpoint | An existing AI endpoint |
| [Email](/docs/infrastructure/email) | Send and receive mail. | SES | Not supported | Not supported | Not supported | Not supported |
## Networking and access
Usually added by Alien. Declare them to change the defaults.
| Resource | What it is | AWS | Google Cloud | Azure | Kubernetes | Linux servers |
|---|---|---|---|---|---|---|
| [Network](/docs/infrastructure/network) | The VPC or VNet your deployment runs in. | VPC | VPC | VNet | Cluster network | Host network |
| [ServiceAccount](/docs/infrastructure/supporting-resources#serviceaccount) | The cloud identity each workload runs as. | IAM role | Service account | Managed identity | Helm chart ServiceAccounts | Not supported |
| [ArtifactRegistry](/docs/infrastructure/supporting-resources#artifactregistry) | The registry your images are pulled from. | ECR | Artifact Registry | Container Registry | An existing service (external binding) | Not supported |
| [Build](/docs/infrastructure/supporting-resources#build) | On-demand build jobs. | CodeBuild | Cloud Build | Container Apps jobs | Kubernetes Jobs | Not supported |
## On each platform [#on-each-platform]
Pick a platform and a compute choice to see what a small stack becomes. The customer's admin makes the same choice during initial setup:
On Kubernetes and the customer's own machines, data resources such as Storage, Kv, and Queue usually point at services the customer already runs, through an [external binding](/docs/infrastructure/platforms/kubernetes#connect-the-customers-infrastructure).
## Platforms [#platforms]
Each platform guide covers what the customer's admin needs for initial setup there, what gets created, and how updates arrive afterwards.
[Platforms](/docs/reference/platforms) has the full support matrix.
## How resources work [#how-resources-work]
Declare, link, and call a resource from your code.
Pick a Worker, Container, or Daemon for each workload.
What the customer's admin creates once, and what Alien manages on every release.
Choose exactly what each workload can do.
# Key (/docs/infrastructure/key)
A Key is an encryption key in your customer's own AWS KMS, Cloud KMS, or Azure Key Vault. Storage, databases, and volumes encrypt with it, so the customer controls whether their data can be read: the key never leaves their account, and they can revoke it at any time.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const customerKey = new alien.Key("customer-key").build()
const files = new alien.Storage("files").encryptionKey(customerKey).build()
const api = new alien.Worker("api")
.code({ type: "source", src: "./api", toolchain: { type: "typescript" } })
.link(customerKey)
.permissions("execution")
.build()
export default new alien.Stack("acme")
.add(customerKey, "frozen")
.add(files, "frozen")
.add(api, "live")
.permissions({
profiles: { execution: { "customer-key": ["key/remote-cryptography"] } },
})
.build()
```
In this stack:
* `.link(customerKey)` connects the key to the `api` Worker, the **workload** (the running code) that uses it. Alien injects the key's connection details, its **binding**, into the Worker, read by the SDK client.
* The `execution` [permission profile](/docs/guide/permissions), the named set of cloud permissions a workload runs with, grants `key/remote-cryptography`: encrypting and decrypting small values.
* `"frozen"` means the customer's admin creates the key during initial setup. `"live"` resources, like the Worker, are created and updated by Alien on every release. See [Frozen and live](/docs/guide/frozen-and-live).
The `api` Worker encrypts small values with the key:
```ts title="api/src/seal.ts"
import { key } from "@alienplatform/bindings"
const ciphertext = await key("customer-key").encrypt(Buffer.from("small secret"))
```
## Platforms [#platforms]
`customer-key` becomes a key in the customer's own key service:
| Platform | Backing service | Created by |
| ---------- | -------------------------------------------------------------------------------------------- | ------------- |
| AWS | A symmetric KMS key, or an existing KMS key in the same account and region (Terraform setup) | Initial setup |
| GCP | A Cloud KMS key ring and an `ENCRYPT_DECRYPT` key | Initial setup |
| Azure | A Key Vault with an RSA 2048 key | Initial setup |
| Kubernetes | Not supported | |
| Machines | Not supported | |
| Local | Not supported | |
There is no external binding for a Key.
## Bring your own key [#bring-your-own-key]
Key is the in-deployment way for a customer to bring their own key (BYOK). Revoking it makes the data it protects unreadable until they restore it. Setting it up and revoking it are covered in [Customer-managed keys in a deployment](/docs/guide/customer-managed-keys/in-a-deployment).
If your product stays hosted in your cloud, use the Encrypt/Decrypt API instead. See [Customer-managed keys](/docs/guide/customer-managed-keys).
## Initial setup only [#initial-setup-only]
A Key is always `"frozen"`: the customer's admin creates it once, during [initial setup](/docs/guide/frozen-and-live), with their own credentials. Ongoing management never creates or replaces it.
* A stack with a `Key` deploys only through the CloudFormation or Terraform setup method.
* The CLI setup method, including `alien deploy`, refuses it before creating anything, with `SETUP_METHOD_CANNOT_INSTALL_RESOURCES`.
* Linking a Key grants nothing by default. Name `key/remote-cryptography` in the profile for code that encrypts or decrypts.
Key has no options besides `.enabled(input)`.
## Which resources accept a key [#which-resources-accept-a-key]
| Resource | Option | Platforms | What it encrypts |
| ----------- | --------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `Storage` | `.encryptionKey(key)` | AWS, GCP, Azure | Objects. S3 uses SSE-KMS, Cloud Storage sets the bucket's default KMS key, Azure encrypts the storage account. The Storage must be `"frozen"`. |
| `Postgres` | `.encryptionKey(key)` | AWS | Aurora storage, snapshots, and Performance Insights data. |
| `Container` | `.persistentStorage(size, { encryptionKey })` | AWS | EBS volumes and their snapshots. |
* `encryptionKey` must be an `alien.Key`. Anything else throws `INVALID_ENCRYPTION_KEY`.
* The Key must be in the same stack.
* A stack that uses a key on a platform that doesn't support it fails the build.
* On Azure, the key applies to the whole storage account, so a stack that encrypts Storage with a key can contain only one Storage.
* The key on a resource can't be added, removed, or changed after the resource exists.
## Use it from code [#use-it-from-code]
`encrypt` and `decrypt` take up to 128 bytes and an optional context, a map of strings. Decrypt with the same context you encrypted with, or it fails.
```ts title="src/seal.ts"
import { key } from "@alienplatform/bindings"
const customerKey = key("customer-key")
const context = { recordType: "credential", recordId: "rec_123" }
const ciphertext = await customerKey.encrypt(Buffer.from("small secret"), { context })
const plaintext = await customerKey.decrypt(ciphertext, { context })
```
`key` is exported from `@alienplatform/bindings`, not `@alienplatform/sdk`.
```python title="seal.py"
from alienplatform import key
customer_key = key("customer-key")
context = {"recordType": "credential", "recordId": "rec_123"}
ciphertext = await customer_key.encrypt(b"small secret", context)
plaintext = await customer_key.decrypt(ciphertext, context)
```
```rust title="src/seal.rs"
use alien_bindings::Key; // the trait isn't re-exported from alien-sdk
use alien_sdk::Bindings;
use std::collections::BTreeMap;
let bindings = Bindings::from_env()?;
let customer_key = bindings.key("customer-key").await?;
let context = BTreeMap::from([("recordType".to_string(), "credential".to_string())]);
let ciphertext = customer_key.encrypt(b"small secret", Some(&context)).await?;
let plaintext = customer_key.decrypt(&ciphertext, Some(&context)).await?;
```
| Method | TypeScript | Python | Rust |
| ------- | ---------------------------------------------- | --------------------------------------------- | ----------------------------------------------- |
| Encrypt | `encrypt(plaintext, { context? })` → `Buffer` | `encrypt(plaintext, context=None)` → `bytes` | `encrypt(&[u8], Option<&BTreeMap>)` → `Vec` |
| Decrypt | `decrypt(ciphertext, { context? })` → `Buffer` | `decrypt(ciphertext, context=None)` → `bytes` | `decrypt(&[u8], Option<&BTreeMap>)` → `Vec` |
Put values that identify the record, such as its type and ID, in the context. A ciphertext copied to another record then fails to decrypt.
To encrypt more than 128 bytes:
1. Generate a random data key.
2. Encrypt your data with it locally.
3. Encrypt only the data key with the Key, and store the encrypted data key next to the data.
For software you host outside the customer's cloud, publish the Key as a [remote binding](/docs/guide/remote-bindings) and call `encrypt` and `decrypt` from your backend. A remotely published Key must be the stack's only `remoteAccess` resource.
### How values are encrypted [#how-values-are-encrypted]
The SDK wraps the plaintext in a small frame: the bytes `AKBF`, a version byte, the plaintext length, and the SHA-256 hash of the encoded context. The frame is then encrypted by the cloud:
| Platform | Call | Context binding |
| -------- | --------------------------------------------------- | ------------------------------------------------------------------- |
| AWS | KMS `Encrypt` / `Decrypt` | Passed as the KMS encryption context, and hashed into the frame. |
| GCP | Cloud KMS `encrypt` / `decrypt` | Passed as additional authenticated data, and hashed into the frame. |
| Azure | Key Vault `encrypt` / `decrypt` with `RSA-OAEP-256` | Hashed into the frame. |
Ciphertexts are only readable through the same Key and its cloud. They aren't portable between clouds.
### Errors [#errors]
| Code | Retryable | When |
| ------------------------ | --------- | ---------------------------------------------------------------------------------------- |
| `KEY_INPUT_INVALID` | No | Plaintext over 128 bytes, or an encoded context over 16 KiB. |
| `KEY_CIPHERTEXT_INVALID` | No | The decrypted data isn't an Alien frame, has a bad length, or the context doesn't match. |
| `BINDING_NOT_CONFIGURED` | No | The workload isn't linked to this Key. |
Cloud failures, including a revoked key, keep the cloud error.
## Rotation [#rotation]
| Platform | Rotation |
| -------- | --------------------------------------------------------------------------------------------------------------- |
| AWS | Automatic yearly rotation is on. KMS keeps earlier key material, so earlier ciphertexts still decrypt. |
| GCP | A new primary key version every 90 days. Earlier versions stay enabled, so earlier ciphertexts still decrypt. |
| Azure | No rotation policy. Storage uses the versionless key ID, so Azure Storage follows a new key version on its own. |
On Azure, `encrypt` and `decrypt` from code use the key's current version, and the ciphertext doesn't record which version encrypted it. After the customer creates a new key version, values your code encrypted earlier may no longer decrypt through the binding.
Re-encrypt those values before rotating an Azure key.
Rotation keeps the same key. Replacing the key with a different one, for example a new KMS key ARN, isn't allowed on an existing deployment.
## Revoke and restore [#revoke-and-restore]
The customer can disable the key, schedule it for deletion, or remove Alien's access to it at any time.
1. Alien's management identity checks the key regularly. A KMS `AccessDenied` or `NotFound`, or a 403 or 404 from Cloud KMS or Key Vault, marks the key unavailable, and the deployment shows **Customer key unavailable**. Throttling and server errors don't.
2. While the key is unavailable, Alien stops updating every resource that uses it. It never deletes or replaces a resource to work around a missing key.
3. Each cloud service stops reading the data on its own schedule. A running Aurora cluster, for example, keeps its data key in memory for a while before it stops serving.
4. Calls to `encrypt` and `decrypt` from code fail with the cloud's access error.
When the customer restores access, the same buckets, databases, and volumes come back with their data. On AWS, Alien restarts an Aurora cluster that stopped because of the key, using `postgres/key-recovery`.
Treat a revoked key as an expected state in your product: show it to the customer, and don't fall back to a copy of their data elsewhere. See [Customer-managed keys in a deployment](/docs/guide/customer-managed-keys/in-a-deployment#revoke-and-restore).
## What initial setup creates [#what-initial-setup-creates]
| | AWS | GCP | Azure |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Key | Symmetric KMS key | `ENCRYPT_DECRYPT` key in a new key ring | RSA 2048 key with `encrypt`, `wrapKey`, and `unwrapKey` |
| Rotation | Yearly, automatic | Every 90 days | None |
| Deletion | 30-day pending window. CloudFormation retains the key, and Terraform refuses to destroy it. | Terraform refuses to destroy it. Cloud KMS keys can't be deleted, only their versions. | Key Vault with purge protection and 90-day soft delete. Terraform refuses to destroy it. |
| Bring your own | With Terraform, initial setup accepts `_existing_key`: a KMS key ARN in the same account and region. Aliases aren't accepted. | | |
Only initial setup creates or deletes the key. Deleting the deployment leaves it in the customer's account.
## Limits [#limits]
| Limit | Value |
| ---------------------------- | ------------------------------- |
| Plaintext per `encrypt` call | 128 bytes |
| Encoded context | 16 KiB |
| Key on a resource | Fixed after the resource exists |
| Azure Storage with a key | One Storage per stack |
## Permissions [#permissions]
| Set | Grants |
| ------------------------------ | -------------------------------------------------------------------------------- |
| `key/remote-cryptography` | Encrypting and decrypting with the key from code. |
| `key/native-storage` | Letting a workload read and write Storage encrypted with the key. |
| `key/native-volume` | EBS encryption with the key through EC2, including service-created grants (AWS). |
| `key/native-volume-management` | Creating EBS volumes encrypted with the key, without decrypting (AWS). |
| `key/availability` | Checking that the key works by encrypting a probe, without decrypting data. |
| `key/management` | Reading key metadata for health checks and drift detection. |
## Next [#next]
* [Customer-managed keys in a deployment](/docs/guide/customer-managed-keys/in-a-deployment)
* [Customer-managed keys](/docs/guide/customer-managed-keys)
* [Storage](/docs/infrastructure/storage)
* [Postgres](/docs/infrastructure/postgres)
# KV API reference (/docs/infrastructure/kv/api)
The KV API is the set of methods your code calls on a linked [KV](/docs/infrastructure/kv) store, in the TypeScript, Python, and Rust SDKs. A **binding** is the connection details Alien injects into each workload [linked](/docs/infrastructure/using-resources#link) to the resource, which the SDK client reads.
A handle comes from `kv(name)` in `@alienplatform/sdk` (TypeScript) or `alienplatform` (Python), or `Bindings::from_env()?.kv(name).await?` in `alien-sdk` (Rust). `name` is the resource ID from `alien.ts`.
Creating the handle does no I/O. The binding resolves on the first call and is cached. If the workload isn't linked to the resource, the first call throws `BINDING_NOT_CONFIGURED`.
## Methods at a glance [#methods-at-a-glance]
| TypeScript | Python | Rust | What it does |
| ----------------------------------- | ----------------------------------------------------- | ------------------------------------ | --------------------------------------------- |
| [`get`, `getText`, `getJson`](#get) | `get(key)` | `get(key)` | Read an entry and its version. |
| [`set`, `setJson`](#set) | `put(key, value, *, ttl_seconds, condition, version)` | `put(key, value, options)` | Write, optionally with a TTL and a condition. |
| [`delete`](#delete) | `delete(key, *, if_version)` | `delete(key, if_version)` | Delete, optionally only if unchanged. |
| [`exists`](#exists) | `exists(key)` | `exists(key)` | Check for a key without reading its value. |
| [`scan`](#scan) | `scan(prefix, *, limit, cursor)` | `scan_prefix(prefix, limit, cursor)` | Page through keys under a prefix. |
Every key is validated before any request: 1 to 512 bytes, characters `a-z A-Z 0-9 - _ : .` only. An invalid key fails with `INVALID_INPUT`.
## get [#get]
Reads the entry for `key`. Returns `null` (Python `None`) when the key is absent or expired.
```ts
const raw = await store.get("user:123") // KvEntry | null
const text = await store.getText("greeting") // KvEntry | null
const user = await store.getJson("user:123") // KvEntry | null
if (user) console.log(user.value.name, user.version)
```
```python
entry = await store.get("user:123") # KvEntry | None
if entry:
user = json.loads(entry.value)
```
```rust
if let Some(entry) = store.get("user:123").await? {
let user: User = serde_json::from_slice(&entry.value)?;
}
```
**Returns** a [`KvEntry`](#kventry): the key, the value, and an opaque `version` for a later conditional write.
* `getText` decodes the value as UTF-8.
* `getJson` parses it with `JSON.parse` and throws if it isn't valid JSON.
## set [#set]
Writes `value` to `key`.
* `set` takes a string and stores its UTF-8 bytes.
* `setJson` serializes the value with `JSON.stringify` first.
* Python and Rust take bytes.
```ts
await store.set("greeting", "hello")
await store.setJson("user:123", { name: "Ada" }, { ttl: 3600 })
// Create only if absent (or expired)
const created = await store.setJson("lock:job-1", { owner: "a" }, { ifVersion: null, ttl: 60 })
// Replace only if unchanged since the read
const applied = await store.setJson("user:123", updated, { ifVersion: entry.version })
```
```python
await store.put("greeting", b"hello")
await store.put("user:123", data, ttl_seconds=3600)
created = await store.put("lock:job-1", b"a", condition="absent", ttl_seconds=60)
applied = await store.put("user:123", data, condition="version", version=entry.version)
```
```rust
use alien_sdk::traits::{PutCondition, PutOptions};
store.put("greeting", b"hello".to_vec(), None).await?;
let created = store.put("lock:job-1", b"a".to_vec(), Some(PutOptions {
ttl: Some(Duration::from_secs(60)),
condition: PutCondition::Absent,
})).await?;
let applied = store.put("user:123", data, Some(PutOptions {
condition: PutCondition::Version(entry.version.clone()),
..Default::default()
})).await?;
```
| Parameter | Type | Description |
| ------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key` | `string` | 1 to 512 bytes, `a-z A-Z 0-9 - _ : .` |
| `value` | `string` (`set`), any JSON value (`setJson`) | See the [value limit](/docs/infrastructure/kv/behavior#limits) per platform. |
| `options.ttl` | `number` | Seconds until the key expires. Omit for no expiry. |
| `options.ifVersion` | `string \| null` | `null`: write only if the key is absent or expired. A version string: write only if the key still has that version. Omit for an unconditional write. |
Python uses `ttl_seconds`, and `condition="absent"` or `condition="version"` with `version=`. Rust uses [`PutOptions`](#putoptions).
**Returns** `boolean`. An unconditional write always returns `true`. A conditional write returns `false` when its condition doesn't hold, and writes nothing.
**Errors:** `INVALID_INPUT` for an invalid key, a value over the limit, or a version that belongs to a different key.
## delete [#delete]
Deletes `key`.
TypeScript:
```ts
await store.delete("user:123")
const deleted = await store.delete("lock:job-1", { ifVersion: lock.version })
```
Python:
```python
await store.delete("user:123")
deleted = await store.delete("lock:job-1", if_version=lock.version)
```
Rust:
```rust
store.delete("user:123", None).await?;
let deleted = store.delete("lock:job-1", Some(&lock.version)).await?;
```
**Returns** `boolean`. An unconditional delete returns `true`, including when the key didn't exist. A conditional delete returns `false` when the key is absent, expired, or changed.
## exists [#exists]
Returns whether `key` exists and hasn't expired, without reading its value.
```ts
if (await store.exists("user:123")) { /* ... */ }
```
## scan [#scan]
Returns one page of entries whose key starts with `prefix`, values included.
```ts
const seen = new Set()
let cursor: string | undefined
do {
const page = await store.scan("user:", 100, cursor)
for (const item of page.items) {
if (seen.has(item.key)) continue // pages can repeat keys
seen.add(item.key)
console.log(item.key, item.value.toString("utf8"))
}
cursor = page.nextCursor
} while (cursor)
```
```python
cursor = None
while True:
page = await store.scan("user:", limit=100, cursor=cursor)
for item in page.items:
print(item.key)
cursor = page.next_cursor
if cursor is None:
break
```
```rust
let mut cursor = None;
loop {
let page = store.scan_prefix("user:", Some(100), cursor).await?;
for entry in page.items {
println!("{}", entry.key);
}
cursor = page.next_cursor;
if cursor.is_none() { break; }
}
```
| Parameter | Type | Description |
| --------- | ------------------- | ------------------------------------------------------------------------------------------------ |
| `prefix` | `string` | Key prefix. Validated like a key. |
| `limit` | `number` (optional) | Maximum items on this page. Defaults to 1,000. A page can hold fewer, even when more keys match. |
| `cursor` | `string` (optional) | `nextCursor` from the previous page. Only valid with the same prefix. |
**Returns** [`KvScanResult`](#kvscanresult): `items` with key, value, and version, and `nextCursor`, which is absent when there are no more pages. Read the [scan semantics](/docs/infrastructure/kv/behavior#scan) before relying on order or completeness.
## Types [#types]
### KvEntry [#kventry]
```ts
interface KvEntry {
key: string
value: T // Buffer, string, or parsed JSON depending on the method
version: string // Opaque. Only pass it back as ifVersion.
}
```
Python `KvEntry(key: str, value: bytes, version: str)`. Rust `KvEntry { key: String, value: Vec, version: String }`.
### KvSetOptions [#kvsetoptions]
```ts
interface KvSetOptions {
ttl?: number // seconds
ifVersion?: string | null // null: must be absent; string: must match
}
```
### KvDeleteOptions [#kvdeleteoptions]
```ts
interface KvDeleteOptions {
ifVersion?: string
}
```
### KvScanResult [#kvscanresult]
```ts
interface KvScanResult {
items: KvEntry[]
nextCursor?: string
}
```
Python `KvPage(items: tuple[KvEntry, ...], next_cursor: str | None)`. Rust `ScanResult { items: Vec, next_cursor: Option }`.
### PutOptions [#putoptions]
Rust only.
```rust
pub struct PutOptions {
pub ttl: Option,
pub condition: PutCondition,
}
pub enum PutCondition {
None, // default: unconditional
Absent, // create only if absent or expired
Version(String), // replace only if the version still matches
}
```
## Errors [#errors]
| Code | Retryable | When |
| ------------------------ | --------- | --------------------------------------------------------------------------------------------- |
| `INVALID_INPUT` | No | Invalid key or prefix, value over the limit, a version for another key, or an invalid cursor. |
| `KV_OPERATION_FAILED` | Yes | A local store operation failed. |
| `BINDING_NOT_CONFIGURED` | No | The workload isn't linked to this resource. |
Cloud backend failures keep the cloud client's error code and `retryable` flag, for example `CLOUD_PLATFORM_ERROR`, `REMOTE_ACCESS_DENIED`, `RATE_LIMIT_EXCEEDED`, or `TIMEOUT`.
A failed condition is not an error. It returns `false`.
# KV behavior and limits (/docs/infrastructure/kv/behavior)
What a [KV](/docs/infrastructure/kv) store guarantees on every platform, its limits, and how each cloud's backing service differs.
## Guarantees [#guarantees]
**Strong single-key consistency.** A `get` after a successful `set` on the same key returns the new value. Alien uses strongly consistent DynamoDB reads; Firestore is strongly consistent, and Table Storage is strongly consistent within its region.
**Atomic conditional writes.** `set` and `delete` with `ifVersion` are atomic on every platform. When two callers race on the same key, exactly one wins and the other gets `false`. Locks, idempotency checks, and read-modify-write loops are safe.
**Idempotent deletes.** An unconditional delete of a missing key succeeds.
**Durability.** Each cloud replicates the data. DynamoDB and Firestore replicate across zones in the region. Table Storage uses the stack's storage account, which is locally redundant (three copies in one datacenter).
On Kubernetes, the guarantees are those of the service in the external binding.
## Versions and conditional writes [#versions-and-conditional-writes]
Every read (`get`, `getText`, `getJson`, `scan`) returns an opaque `version` with the value. It changes on every applied write.
* **Create if absent:** `set(key, value, { ifVersion: null })` writes only when the key is absent or expired. Of several concurrent creators, exactly one succeeds.
* **Compare and set:** `set(key, value, { ifVersion: entry.version })` replaces the value only if the key still has that version.
* **Compare and delete:** `delete(key, { ifVersion: entry.version })` deletes only if the key is unchanged.
A failed condition resolves `false` and writes nothing. It isn't an error.
Rules for versions:
* A version is bound to the key it was read from. Passing a version from another key fails with `INVALID_INPUT`.
* Don't parse, compare, or store versions. Their only use is passing one back as `ifVersion`.
* A version read from an entry that has since expired never matches, because conditions treat expired keys as absent.
Alien maps each condition onto the backend's own atomic operation, with no client-side locking:
| Backend | Atomic operation |
| ------------- | ---------------------------------------- |
| DynamoDB | Condition expressions |
| Firestore | Update-time preconditions |
| Table Storage | Entity tags (ETags) |
| Local | Conditional update in the local database |
When a key has expired but its row still exists, a create-if-absent write takes over the row. That write is conditioned on the expired row's version, so a racing writer can't be overwritten.
## Limits [#limits]
Alien checks these before calling the backend, and rejects violations with `INVALID_INPUT`:
| Limit | Value |
| ------------------------ | ----------------------- |
| Key | 1 to 512 bytes |
| Key characters | `a-z A-Z 0-9 - _ : .` |
| Value on Azure and Local | 24 KiB (24,576 bytes) |
| Value on AWS | 399 KiB (408,576 bytes) |
| Value on GCP | 765 KiB (783,360 bytes) |
| Scan page | 1,000 items by default |
For code that runs on every platform, keep values under 24 KiB.
## TTL [#ttl]
A TTL is a hint for cleanup, not a deletion schedule. Once a key's expiry passes, reads treat it as absent: `get` returns `null`, `exists` returns `false`, and `scan` skips it.
When the stored row is actually deleted depends on the platform:
| Platform | Physical deletion |
| -------- | -------------------------------------------------------------------------------------------------------------------------------- |
| AWS | DynamoDB's TTL sweeper deletes expired items, typically within a few days of expiry. Expiry is stored with one-second precision. |
| GCP | Alien doesn't configure a Firestore TTL policy. Expired documents stay until overwritten or deleted. |
| Azure | Table Storage has no TTL. Expired entities stay until overwritten or deleted. |
| Local | Expired rows are deleted when read. |
Don't rely on storage being reclaimed at a particular time.
## Scan [#scan]
Scan is deliberately weak so it behaves the same on every backend:
* **Unordered.** Items come in the backend's natural order. On DynamoDB and Table Storage the store is spread over 16 partitions, so results are grouped by partition, not by key.
* **Short pages.** A page can hold fewer than `limit` items even when more keys match. It can even be empty while `nextCursor` is still set, for example when entries expired between pages.
* **Duplicates.** A key can appear on more than one page. De-duplicate on the client.
* **Not a snapshot.** Concurrent writes may or may not appear.
* **Complete without concurrent writes.** If nothing changes during the scan, following every `nextCursor` until it's absent visits every matching, unexpired key.
* **Cursors are short-lived.** A cursor is bound to its prefix, can stop working after the data changes, and has no guaranteed lifetime. Don't persist or share it.
* **Invalid cursors fail.** An invalid cursor returns an error, not partial results.
## Per-cloud details [#per-cloud-details]
### AWS (DynamoDB) [#aws-dynamodb]
* On-demand (pay-per-request) capacity. No capacity planning.
* Keys are spread over 16 hash buckets in the partition key, so traffic to similar keys doesn't land on one partition.
* TTL is enabled on the table's `ttl` attribute.
* Tables that the setup template creates have server-side encryption and point-in-time recovery turned on.
* A DynamoDB partition serves up to 3,000 read units and 1,000 write units per second. Spreading keys over buckets keeps a single hot prefix from hitting that limit.
### GCP (Firestore) [#gcp-firestore]
* Each KV resource is a collection in the project's `(default)` Firestore database. Alien creates the database if the project doesn't have one.
* Firestore is strongly consistent.
* New collections start at about 500 writes per second and scale by 50% every 5 minutes under sustained load.
* No TTL policy; see [TTL](#ttl).
### Azure (Table Storage) [#azure-table-storage]
* Each KV resource is a table in the stack's storage account. Keys are spread over 16 partitions.
* No native TTL. Alien filters expired entities on read but never deletes them.
* A partition serves up to 2,000 entities per second.
* Table Storage limits a property to 64 KiB. Values are base64-encoded and stored as UTF-16, which is why the value limit is 24 KiB.
### Kubernetes [#kubernetes]
Alien creates nothing. Point each KV resource at DynamoDB, Firestore, or Table Storage in the Helm values. The limits above still apply.
### Local [#local]
* An embedded database file, `localkv.sqlite`, in the local data directory. Data survives restarts, and several processes can share it.
* Expired rows are deleted when read.
* Scan returns keys in sorted order. Don't rely on that; the cloud backends don't.
## Design decisions [#design-decisions]
**512-byte keys.** DynamoDB sort keys and Table Storage row keys allow 1,024 bytes and Firestore document IDs 1,500. 512 bytes fits all of them.
**Restricted key characters.** Table Storage rejects `/`, `\`, `#`, `?`, and control characters in keys, and other characters cause encoding problems across backends. One character set everywhere means a key that works locally works in every cloud.
**Per-platform value limits.** Each backend accepts what fits its own item or document size, after encoding. The 24 KiB Azure limit is the portable floor. Store larger objects in [Storage](/docs/infrastructure/storage) and keep a pointer in KV.
**Opaque versions.** Versions wrap each backend's own concurrency token. Keeping them opaque lets conditional writes use native atomic operations instead of an extra version column or a lock.
# KV (/docs/infrastructure/kv)
KV is a key-value store in the customer's cloud for fast lookups by key: caches, sessions, feature flags, idempotency keys, rate limits, and locks. One API, with TTLs and atomic conditional writes, runs on DynamoDB, Firestore, Table Storage, or a local database.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const sessions = new alien.Kv("sessions").build()
const api = new alien.Worker("api")
.code({ type: "source", src: "./api", toolchain: { type: "typescript" } })
.link(sessions)
.permissions("execution")
.build()
export default new alien.Stack("acme")
.add(sessions, "frozen")
.add(api, "live")
.permissions({
profiles: { execution: { sessions: ["kv/data-read", "kv/data-write"] } },
})
.build()
```
In this stack:
* `.link(sessions)` connects the store to the `api` Worker, the **workload** (the running code) that uses it. Alien injects the store's connection details, its **binding**, into the Worker, read by the SDK client.
* The `execution` [permission profile](/docs/guide/permissions), the named set of cloud permissions a workload runs with, grants `kv/data-read` and `kv/data-write`.
* `"frozen"` means the customer's admin creates the store during initial setup. `"live"` resources, like the Worker, are created and updated by Alien on every release. See [Frozen and live](/docs/guide/frozen-and-live).
The `api` Worker reads and writes values by key:
```ts title="api/src/sessions.ts"
import { kv } from "@alienplatform/sdk"
const sessions = kv("sessions")
await sessions.setJson("session:abc", { userId: "u1" }, { ttl: 3600 })
const entry = await sessions.getJson<{ userId: string }>("session:abc")
```
## Platforms [#platforms]
`sessions` becomes a table or collection in the customer's cloud:
| Platform | Backing service | Created by |
| ---------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| AWS | DynamoDB table, on-demand capacity | Alien |
| GCP | Firestore collection in the project's `(default)` database | Alien |
| Azure | Table Storage table in the stack's storage account | Alien |
| Kubernetes | DynamoDB, Firestore, or Table Storage | The customer, through an [external binding](/docs/infrastructure/platforms/kubernetes#connect-the-customers-infrastructure) |
| Machines | Same services as Kubernetes | The customer, through an external binding. The resource must be `"frozen"`. See [Machines](/docs/infrastructure/platforms/machines). |
| Local | Embedded database file (`localkv.sqlite`) | Alien |
* **Kubernetes:** Alien doesn't create the store. The build fails unless each KV resource has an external binding of type `kv`.
## Frozen or live [#frozen-or-live]
KV can be `"frozen"` (created once during initial setup by the customer's admin) or `"live"` (created and updated by Alien on releases). See [Frozen and live](/docs/guide/frozen-and-live).
The resource ID must be lowercase letters, digits, and hyphens.
## Alternatives [#alternatives]
KV isn't for queries, joins, or values larger than the [value limit](/docs/infrastructure/kv/behavior#limits).
* Relational data: [Postgres](/docs/infrastructure/postgres).
* Large objects: [Storage](/docs/infrastructure/storage).
## Options [#options]
KV has no options besides `.enabled(input)`, which creates the resource only when a boolean [stack input](/docs/guide/inputs-and-secrets) is `true`. The backing service follows from the platform.
## Use it from code [#use-it-from-code]
Get a handle with `kv(name)`. Creating it does no I/O; the binding resolves on the first call.
```ts title="src/sessions.ts"
import { kv } from "@alienplatform/sdk"
const sessions = kv("sessions")
await sessions.setJson("session:abc", { userId: "u1" }, { ttl: 3600 })
const entry = await sessions.getJson<{ userId: string }>("session:abc")
// entry: { key, value: { userId: "u1" }, version } or null
const page = await sessions.scan("session:", 100)
for (const item of page.items) console.log(item.key)
```
```python title="sessions.py"
import json
from alienplatform import kv
sessions = kv("sessions")
await sessions.put("session:abc", json.dumps({"userId": "u1"}).encode(), ttl_seconds=3600)
entry = await sessions.get("session:abc") # KvEntry(key, value, version) or None
page = await sessions.scan("session:", limit=100)
for item in page.items:
print(item.key)
```
```rust title="src/sessions.rs"
use alien_sdk::{traits::PutOptions, Bindings};
use std::time::Duration;
let bindings = Bindings::from_env()?;
let sessions = bindings.kv("sessions").await?;
sessions.put("session:abc", br#"{"userId":"u1"}"#.to_vec(), Some(PutOptions {
ttl: Some(Duration::from_secs(3600)),
..Default::default()
})).await?;
let entry = sessions.get("session:abc").await?; // Option
```
The full method list is in the [API reference](/docs/infrastructure/kv/api).
## Common patterns [#common-patterns]
### Compare and set [#compare-and-set]
Every read returns an opaque `version`, so read-modify-write loops are safe under concurrency. Pass the version back to write only if nobody changed the key since you read it:
```ts
const entry = await counters.getJson<{ n: number }>("visits")
const next = { n: (entry?.value.n ?? 0) + 1 }
const applied = await counters.setJson("visits", next, {
ifVersion: entry ? entry.version : null, // null: create only if absent
})
if (!applied) {
// Another writer won. Read again and retry.
}
```
A failed condition isn't an error. The call resolves `false` and writes nothing.
### Distributed lock [#distributed-lock]
```ts
const acquired = await locks.setJson("lock:report", { owner: workerId }, {
ifVersion: null, // create only if absent or expired
ttl: 30,
})
if (acquired) {
try {
await generateReport()
} finally {
// Release only our own lock. If it expired and another worker took it,
// the version no longer matches and nothing is deleted.
const lock = await locks.getJson<{ owner: string }>("lock:report")
if (lock?.value.owner === workerId) {
await locks.delete("lock:report", { ifVersion: lock.version })
}
}
}
```
### Cache with TTL [#cache-with-ttl]
```ts
async function getUser(id: string) {
const cached = await cache.getJson(`user:${id}`)
if (cached) return cached.value
const user = await loadUser(id)
await cache.setJson(`user:${id}`, user, { ttl: 300 })
return user
}
```
Keys allow only `a-z A-Z 0-9 - _ : .`. Use `:` to build namespaces such as `user:123`.
Expired keys read as absent immediately. Physical deletion depends on the platform; see [TTL](/docs/infrastructure/kv/behavior#ttl).
### Use it from your hosted software [#use-it-from-your-hosted-software]
Publish the store as a [remote binding](/docs/guide/remote-bindings) to read and write it from your own backend with the same API.
## Permissions [#permissions]
| Set | Grants |
| ----------------------------------------------- | -------------------------------------------------------------------------------------- |
| `kv/data-read` | `get`, `exists`, and `scan`. |
| `kv/data-write` | Read and write, including `set` and `delete`. |
| `kv/remote-data-write` | Entry operations on this one store from your hosted software through a remote binding. |
| `kv/provision`, `kv/management`, `kv/heartbeat` | Used by initial setup and by Alien's management identity. |
When a workload's profile doesn't name the store, `.link()` grants `kv/data-write`. Naming the store in the profile replaces that default. See [Permissions](/docs/guide/permissions).
## Next [#next]
* [API reference](/docs/infrastructure/kv/api)
* [Behavior and limits](/docs/infrastructure/kv/behavior)
* [Pricing](/docs/infrastructure/kv/pricing)
# KV pricing (/docs/infrastructure/kv/pricing)
A typical small store costs a few dollars a month or less, on the customer's own cloud bill. They pay their provider for requests and stored data; Alien adds no per-request charge. For Alien's own pricing, see [Pricing](/pricing).
Prices are list prices as of October 2026 in AWS us-east-1, GCP us-central1, and Azure East US. Official pages are under [Sources](#sources).
Firestore bills by database location, and Alien creates the database in the deployment's region. Multi-region locations such as `nam5` cost more.
## Requests [#requests]
| Provider | Service | Reads | Writes |
| -------- | -------------------------- | ------------------------------------- | -------------------------------------- |
| AWS | DynamoDB on-demand | $0.125 per million read request units | $0.625 per million write request units |
| GCP | Firestore Standard edition | $0.03 per 100,000 document reads | $0.09 per 100,000 document writes |
| Azure | Table Storage | $0.00036 per 10,000 transactions | $0.00036 per 10,000 transactions |
What counts as one request differs:
* **DynamoDB:** Alien uses strongly consistent reads, which cost one read unit per 4 KB of item. A write costs one write unit per 1 KB. A conditional write that fails still costs a write unit. A scan page costs read units for every item it reads.
* **Firestore:** each document read or written counts once. Deletes are billed separately at a lower rate.
* **Table Storage:** every operation is one transaction, whatever its size.
## Stored data [#stored-data]
| Provider | Per GB-month |
| ------------------- | ------------ |
| AWS DynamoDB | $0.25 |
| GCP Firestore | $0.15 |
| Azure Table Storage | $0.045 (LRS) |
DynamoDB tables that the setup template creates have point-in-time recovery turned on, billed at $0.20 per GB-month of table size.
Expired keys still count as stored data until they're physically deleted. On Firestore and Table Storage, Alien doesn't delete them; see [TTL](/docs/infrastructure/kv/behavior#ttl).
## Example [#example]
A month with 10 million reads, 1 million writes, and 1 GB stored, with values under 1 KB:
| Provider | Reads | Writes | Storage | Total |
| ------------------- | ----- | ------ | ------- | -------------------------------------------------- |
| AWS DynamoDB | $1.25 | $0.63 | $0.25 | **$2.13** (plus $0.20 with point-in-time recovery) |
| GCP Firestore | $3.00 | $0.90 | $0.15 | **$4.05** |
| Azure Table Storage | $0.36 | $0.04 | $0.05 | **$0.44** |
## Free tiers [#free-tiers]
* **AWS DynamoDB:** 25 GB of storage per month, with no time limit.
* **GCP Firestore:** 1 GiB stored, 50,000 reads, 20,000 writes, and 20,000 deletes per day, with no time limit. Applies to the `(default)` database, which is the one Alien uses.
## Sources [#sources]
* [Amazon DynamoDB pricing](https://aws.amazon.com/dynamodb/pricing/on-demand/)
* [Firestore pricing](https://cloud.google.com/firestore/pricing)
* [Azure Table Storage pricing](https://azure.microsoft.com/en-us/pricing/details/storage/tables/)
# Network behavior and limits (/docs/infrastructure/network/behavior)
The [Network](/docs/infrastructure/network) is the VPC or VNet a deployment runs in. The same `alien.ts` deploys into any customer network, and Alien never changes a network it doesn't own.
## Guarantees [#guarantees]
**Generated, not declared.** Alien adds the Network from stack settings when the stack needs one. Your `alien.ts` stays the same across customer network setups.
**Frozen.** The customer's admin creates the Network during initial setup. The management identity can't change it during ongoing management.
**BYO means no ownership transfer.** In the BYO modes, Alien references and validates existing infrastructure. It doesn't create, change, or delete the VPC or VNet, or add NAT, routes, firewall rules, or peering.
**The mode is fixed.** Changing from one mode to another, such as `create` to `byo-vpc-aws`, is rejected as an invalid update.
**`create` cleans up.** Everything Alien created for the network is deleted with the deployment.
## Limits [#limits]
| Limit | Value | Notes |
| ---------------------- | -------------------------- | ---------------------------------------------- |
| Declared in `alien.ts` | Never | Configure through stack settings or CLI flags. |
| Availability zones | Default 2 | Setup templates on AWS support 1 to 3. |
| Created VPC or VNet | `/16` by default | See [CIDR selection](#cidr-selection). |
| Mode changes | Not allowed | Create a new deployment instead. |
| Egress in BYO modes | Owned by the network owner | Alien creates no NAT, proxy, or VPN. |
| Endpoint access | Fixed at initial setup | `private` is AWS managed containers only. |
## CIDR selection [#cidr-selection]
When `cidr` is omitted in `create` mode, the default depends on who provisions the network.
**Setup templates** (CloudFormation and Terraform) use a fixed default, which the customer can override with the template's VPC CIDR parameter:
| Cloud | Default CIDR |
| ----- | -------------- |
| AWS | `10.42.0.0/16` |
| GCP | `10.44.0.0/16` |
| Azure | `10.46.0.0/16` |
**Alien provisioning directly** picks a `/16` that avoids other networks:
* **AWS**: lists the account's VPCs and takes the first free `/16`, trying `100.64.0.0/10` first (starting at an offset derived from the stack id), then `172.16.0.0/12`, then `10.0.0.0/8`. That's 336 candidates.
* **GCP and Azure**: derives a `/16` in `100.64.0.0/10` from the network id.
Set `cidr` explicitly when the customer will peer the network with others or route to it over a VPN.
## Subnet layout [#subnet-layout]
| Cloud | Subnets in `create` mode |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS, setup templates | One public and one private `/24` per zone, and one NAT Gateway shared by the private subnets. |
| AWS, direct | One public and one private `/20` per zone. Public subnets start at the bottom of the range, private ones at `.128.0`. |
| GCP | One regional subnet covering the network CIDR, a Cloud Router, Cloud NAT, and a firewall rule that allows internal TCP, UDP, and ICMP from the network CIDR. |
| Azure | `/24` subnets carved from the VNet: public, private, Application Gateway, Private Endpoint, and Container Apps. A NAT Gateway with one public IP, and a Network Security Group. |
Sandboxes with restricted egress add their own subnets inside the network.
## Platforms [#platforms]
### AWS [#aws]
* `create` creates a VPC with DNS support and hostnames, public and private subnets, route tables, an Internet Gateway, a NAT Gateway, and a security group.
* `use-default` uses the account's default VPC and its public subnets. Instances get public IPs.
* `byo-vpc-aws` stores the VPC, subnet, and security group references.
### GCP [#gcp]
* `create` creates a custom VPC network, a regional subnetwork, a Cloud Router, Cloud NAT, and a firewall rule.
* `use-default` uses the project's `default` network and regional subnet.
* `byo-vpc-gcp` stores the network, subnet, and region.
### Azure [#azure]
* Azure has no default VNet. `use-default` creates a VNet and NAT Gateway instead of attaching to a cloud default.
* `create` creates a VNet, subnets, a NAT Gateway, a public IP, and a Network Security Group.
* `byo-vnet-azure` stores the VNet resource id and subnet names. A Postgres needs `private_endpoint_subnet_name`.
### Kubernetes, Machines, Local [#kubernetes-machines-local]
The cluster operator or host provides the network. Alien creates no VPC or VNet, except for Kubernetes on a cloud when the stack has a Postgres or an Alien-managed cluster.
## Design decisions [#design-decisions]
**Configured outside `alien.ts`.** The same application deploys into different customer network topologies without changing the stack.
**`create` is the production default.** Alien owns the network it needs to create, update, and delete, and isolates the deployment from the customer's other workloads.
**BYO keeps responsibility with the customer.** Alien never adds NAT, routing, firewall, or peering rules to a network it doesn't own.
# Network (/docs/infrastructure/network)
A Network is the VPC or VNet a deployment's containers, databases, and sandboxes run in, with its subnets, NAT, routing, and firewall rules. You don't declare it: Alien adds it when the stack needs one, and each deployment picks how it's built, so one stack fits every customer's network.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
// A Container and a Postgres need a network, so Alien adds one.
const db = new alien.Postgres("db").build()
const api = new alien.Container("api")
.code({ type: "image", image: "ghcr.io/acme/api:1.4.0" })
.cpu(1)
.memory("1Gi")
.port(8080)
.link(db)
.permissions("execution")
.build()
export default new alien.Stack("acme")
.add(db, "frozen")
.add(api, "live")
.build()
```
## Platforms [#platforms]
With the default `create` mode, the stack above gets an isolated network in the customer's cloud:
| Platform | `create` provisions |
| --------------------------- | -------------------------------------------------------------------------------------------- |
| AWS | VPC, public and private subnets, route tables, Internet Gateway, NAT Gateway, security group |
| GCP | VPC network, regional subnet, Cloud Router, Cloud NAT, firewall rule |
| Azure | VNet, public and private subnets, NAT Gateway, public IP, Network Security Group |
| Kubernetes, Machines, Local | Nothing. The cluster or host network is used. |
Deployment status shows it as **Network**, under **Networking and access**.
## When it's added [#when-its-added]
Alien adds a [frozen](/docs/guide/frozen-and-live) Network named `default-network` on AWS, GCP, and Azure when network settings are given, or when the stack has any of these:
* A Container, Daemon, or ComputeCluster
* A Postgres
* A Sandbox with egress other than `{ mode: "allow" }`
Workers alone don't need a Network.
On Kubernetes, Machines, and Local, the cluster or host network is used and no Network is created. The exception is Kubernetes on a cloud that needs one, such as a stack with Postgres or an Alien-managed cluster.
Because the Network is frozen, the customer's admin creates it once during initial setup. Ongoing management doesn't change it.
## Modes [#modes]
| Mode | Use for | What Alien does |
| ---------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `create` | Production. The default when a Network is needed. | Creates an isolated VPC or VNet with public and private subnets, NAT, routing, and firewall rules. Instances get private IPs only and reach the internet through NAT. Deletes it all with the deployment. |
| `use-default` | Development and tests | Uses the AWS default VPC or the GCP `default` network. Instances get public IPs and no NAT is created. Azure has no default VNet, so Alien creates one with a NAT Gateway. |
| `byo-vpc-aws` | An existing AWS VPC | References your VPC, subnets, and security groups. Creates nothing. |
| `byo-vpc-gcp` | An existing GCP VPC | References your network and subnet. Creates nothing. |
| `byo-vnet-azure` | An existing Azure VNet | References your VNet and subnets. Creates nothing. |
In the BYO modes, the customer owns routing and egress: NAT, proxies, VPNs, and firewall rules. Alien validates the references and assigns no public IPs.
The mode can't change after the Network is created. Moving to another mode means a new deployment.
## Set it with the CLI [#set-it-with-the-cli]
`alien deploy` and `alien deployments create` take network flags.
To create a new network with a chosen address range:
```bash
alien deployments create --name customer-1 --project acme --deployment-group production \
--platform aws --network create --network-cidr 100.88.0.0/16 --availability-zones 3
```
To deploy into a VPC the customer already has:
```bash
alien deployments create --name customer-2 --project acme --deployment-group production \
--platform aws --network byo --vpc-id vpc-0123456789abcdef0 \
--public-subnet-ids subnet-0aaa,subnet-0bbb --private-subnet-ids subnet-0ccc,subnet-0ddd
```
| Flag | Mode | Notes |
| --------------------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------- |
| `--network auto\|use-default\|create\|byo` | | `auto` (default) creates a Network only when the stack needs one. |
| `--network-cidr`, `--availability-zones` | `create` | The CIDR is chosen for you when omitted. Zones default to 2. |
| `--vpc-id`, `--public-subnet-ids`, `--private-subnet-ids`, `--security-group-ids` | `byo` on AWS | Lists are comma-separated. Security groups are optional. |
| `--network-name`, `--subnet-name`, `--network-region` | `byo` on GCP | |
| `--vnet-resource-id`, `--public-subnet-name`, `--private-subnet-name` | `byo` on Azure | |
| `--endpoint-access internet\|private` | any | Who can reach public endpoints. `private` is AWS only. |
## Set it in stack settings [#set-it-in-stack-settings]
For CloudFormation, Terraform, or Helm output, pass a settings file to `alien render`:
```json title="stack-settings.json"
{
"network": {
"type": "byo-vpc-aws",
"vpc_id": "vpc-0123456789abcdef0",
"public_subnet_ids": ["subnet-0aaa", "subnet-0bbb"],
"private_subnet_ids": ["subnet-0ccc", "subnet-0ddd"],
"security_group_ids": ["sg-0123456789abcdef0"]
}
}
```
```bash
alien render --format terraform --target aws \
--stack ./alien.ts \
--stack-settings ./stack-settings.json
```
See [Settings reference](/docs/infrastructure/network/settings) for every field.
## Outputs [#outputs]
| Field | Notes |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `networkId` | Cloud network id, such as the VPC id or VNet name |
| `availabilityZones` | Number of zones |
| `hasPublicSubnets` | Whether public subnets exist |
| `hasNatGateway` | Whether a managed NAT gateway exists |
| `egressPublicIps` | Public addresses of the deployment's NAT. Empty when unknown, including BYO networks. Give these to services that allowlist by IP. |
| `cidr` | CIDR block, when Alien created the network |
## Permissions [#permissions]
| Set | Grants |
| ------------------- | -------------------------------------- |
| `network/provision` | Creating the network at initial setup. |
| `network/heartbeat` | Health checks. |
## Next [#next]
* [Settings reference](/docs/infrastructure/network/settings)
* [Behavior and limits](/docs/infrastructure/network/behavior)
* [Pricing](/docs/infrastructure/network/pricing)
* [Networking](/docs/guide/networking)
* [Follow a deployment](/docs/production/customer-setup)
# Network pricing (/docs/infrastructure/network/pricing)
The VPC or VNet itself is free on every cloud. The fixed cost is NAT, which the customer pays on their own cloud bill.
Alien lists the Network as a free resource on [Pricing](/pricing).
Prices on this page are AWS list prices as of October 2026 in us-east-1. Official pricing: [AWS VPC](https://aws.amazon.com/vpc/pricing/), [Google Cloud VPC](https://cloud.google.com/vpc/network-pricing) and [Cloud NAT](https://cloud.google.com/nat/pricing), [Azure Virtual Network](https://azure.microsoft.com/en-us/pricing/details/virtual-network/) and [Azure NAT Gateway](https://azure.microsoft.com/en-us/pricing/details/azure-nat-gateway/).
## Cost components [#cost-components]
| Component | Applies when | Notes |
| ------------------------ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| NAT Gateway or Cloud NAT | `create` mode, and `use-default` on Azure | Usually the largest fixed network cost for a small deployment. Billed per hour and per GB processed. |
| Public IP addresses | NAT gateways and public load balancers | Varies by cloud and region. |
| Load balancers | Containers with public ports | Billed with the Container, but they sit in the Network. |
| Data processing | NAT, load balancing, cross-zone traffic | Per GB, per cloud. |
| Data transfer | Internet egress and cross-region traffic | Per GB, by destination. |
## By mode [#by-mode]
| Mode | Network resources billed | Notes |
| ---------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `create` | Yes | Alien creates the VPC or VNet, subnets, NAT, routing, and firewall rules. |
| `use-default` | AWS and GCP: no. Azure: yes. | AWS and GCP use the cloud's default network and no NAT. Azure has no default VNet, so Alien creates a VNet and NAT Gateway. |
| `byo-vpc-aws`, `byo-vpc-gcp`, `byo-vnet-azure` | No | Costs stay with the customer's existing network. |
## Example: one AWS deployment in `create` mode [#example-one-aws-deployment-in-create-mode]
A deployment created from the setup template has one NAT Gateway.
| Item | Price | Monthly |
| ------------------------------------------ | --------------- | ---------- |
| NAT Gateway, 730 hours | $0.045 per hour | $32.85 |
| NAT data processing, 100 GB | $0.045 per GB | $4.50 |
| Public IPv4 address for the NAT, 730 hours | $0.005 per hour | $3.65 |
| **Total** | | **$41.00** |
Internet egress is billed on top at standard data transfer rates.
For many small, low-traffic deployments, the hourly NAT charge dominates. `use-default` avoids it in development, and BYO modes reuse NAT the customer already pays for.
## Cost control [#cost-control]
* Use BYO modes when the customer already has central NAT, inspection, or egress controls.
* Use `create` when the deployment should own its network and its cleanup.
* Review NAT and egress costs before deploying many small environments.
* Public containers add load balancer and data processing charges on top of the network baseline.
## Sources [#sources]
* [AWS VPC pricing](https://aws.amazon.com/vpc/pricing/)
* [GCP VPC network pricing](https://cloud.google.com/vpc/network-pricing)
* [Azure Virtual Network pricing](https://azure.microsoft.com/en-us/pricing/details/virtual-network/)
* [Azure NAT Gateway pricing](https://azure.microsoft.com/en-us/pricing/details/azure-nat-gateway/)
# Network settings reference (/docs/infrastructure/network/settings)
Network settings decide how each deployment's [Network](/docs/infrastructure/network) is built. They live in the deployment's **stack settings**, the per-deployment options chosen at initial setup (for example the `NetworkMode` CloudFormation parameter), not in `alien.ts`.
The generated Network resource appears in stack state, but your code doesn't create it, link to it, or call it at runtime.
```ts
type StackSettings = {
network?: NetworkSettings | null
endpointAccess?: "internet" | "private"
}
```
When `network` is omitted and the stack needs a network, Alien uses `{ "type": "create", "availability_zones": 2 }`.
Field names inside `network` use snake case.
## use-default [#use-default]
Uses the cloud's default network. For development and tests.
```json
{ "network": { "type": "use-default" } }
```
| Field | Type | Required | Notes |
| ------ | --------------- | -------- | ----- |
| `type` | `"use-default"` | Yes | |
* **AWS**: the account's default VPC. Subnets are public with auto-assigned IPs. No NAT.
* **GCP**: the project's `default` network and regional subnet. Instances get ephemeral external IPs. No NAT.
* **Azure**: there is no default VNet, so Alien creates one with a NAT Gateway. Instances stay private.
## create [#create]
Creates an isolated network for the deployment.
```json
{
"network": {
"type": "create",
"cidr": "100.88.0.0/16",
"availability_zones": 2
}
}
```
| Field | Type | Required | Notes |
| -------------------- | ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
| `type` | `"create"` | Yes | |
| `cidr` | `string` | No | VPC or VNet CIDR. Chosen for you when omitted. See [CIDR selection](/docs/infrastructure/network/behavior#cidr-selection). |
| `availability_zones` | `number` | No | Default `2`. |
## byo-vpc-aws [#byo-vpc-aws]
References an existing AWS VPC.
```json
{
"network": {
"type": "byo-vpc-aws",
"vpc_id": "vpc-0123456789abcdef0",
"public_subnet_ids": ["subnet-0aaa", "subnet-0bbb"],
"private_subnet_ids": ["subnet-0ccc", "subnet-0ddd"],
"security_group_ids": ["sg-0123456789abcdef0"]
}
}
```
| Field | Type | Required | Notes |
| -------------------- | --------------- | -------- | -------------------------------------------- |
| `type` | `"byo-vpc-aws"` | Yes | |
| `vpc_id` | `string` | Yes | Existing VPC id. |
| `public_subnet_ids` | `string[]` | Yes | Subnets for public ingress. |
| `private_subnet_ids` | `string[]` | Yes | Subnets for instances that get no public IP. |
| `security_group_ids` | `string[]` | No | Security groups to attach. |
## byo-vpc-gcp [#byo-vpc-gcp]
References an existing GCP VPC and subnet.
```json
{
"network": {
"type": "byo-vpc-gcp",
"network_name": "customer-vpc",
"subnet_name": "app-us-central1",
"region": "us-central1"
}
}
```
| Field | Type | Required | Notes |
| -------------- | --------------- | -------- | -------------------------- |
| `type` | `"byo-vpc-gcp"` | Yes | |
| `network_name` | `string` | Yes | Existing VPC network name. |
| `subnet_name` | `string` | Yes | Existing subnet name. |
| `region` | `string` | Yes | Region of the subnet. |
## byo-vnet-azure [#byo-vnet-azure]
References an existing Azure VNet.
```json
{
"network": {
"type": "byo-vnet-azure",
"vnet_resource_id": "/subscriptions/.../virtualNetworks/customer-vnet",
"public_subnet_name": "public",
"private_subnet_name": "private"
}
}
```
| Field | Type | Required | Notes |
| --------------------------------- | ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `type` | `"byo-vnet-azure"` | Yes | |
| `vnet_resource_id` | `string` | Yes | Full resource id of the VNet. |
| `public_subnet_name` | `string` | Yes | |
| `private_subnet_name` | `string` | Yes | Claimed by the Container Apps environment. |
| `application_gateway_subnet_name` | `string` | No | A dedicated subnet for a classic Application Gateway. |
| `private_endpoint_subnet_name` | `string` | No | A dedicated subnet for Private Endpoints. Required when the stack has a Postgres, since a Private Endpoint can't share the private subnet. |
## endpointAccess [#endpointaccess]
| Value | Notes |
| ---------- | ------------------------------------------------------------------------------------------------ |
| `internet` | Default. Public endpoints are reachable from the internet. |
| `private` | Public endpoints are reachable only through the deployment network. AWS managed containers only. |
Fixed at initial setup. To change it, create a new deployment.
# AWS (/docs/infrastructure/platforms/aws)
An AWS deployment runs your stack in your customer's AWS account: its Lambda functions, EC2 machines, buckets, and databases live there, inside the customer's boundary. The customer's admin sets it up once, and Alien rolls out every release after that.
A deployment's life has two parts:
* **Initial setup**: the one-time step the admin runs with their own AWS credentials, using CloudFormation, Terraform, or the CLI.
* **Ongoing management**: everything after. Alien's **manager**, the service that holds your releases and deployments, rolls out each release without the admin.
Initial setup creates the **management role**, the IAM role the manager assumes to change the deployment. On other platforms this is called the management identity.
Initial setup also creates the [frozen resources](/docs/guide/frozen-and-live): the ones only initial setup may create or change, such as the bucket that holds the customer's data. The manager creates and updates the rest, the live resources.
How the admin reaches initial setup is your [Deployment UX](/docs/deployment-ux) choice: a link you send, a Deploy button in your product, or your own setup screen. What happens in AWS is the same either way, and this guide follows it from requirements to removal.
## What the customer needs [#what-the-customer-needs]
* **An AWS account.** A dedicated account for your software is the cleanest boundary, but any account works.
* **Permission to create IAM roles** and every resource type the stack sets up, because initial setup creates the management role, the workload roles, and the frozen resources. In practice, an administrator role in the target account.
* **Service quotas** for the stack. Initial setup checks them first. See [Quotas](#quotas).
* **A supported region.** See [Regions](#regions).
The admin's credentials don't need permissions for what Alien creates later through the management role, such as EC2 machines, Auto Scaling groups, load balancers, and certificates.
## Set up [#set-up]
Each method runs a [setup package](/docs/infrastructure/platforms/setup-packages), which Alien builds from your release. The **deployment portal**, the branded setup page Alien hosts for the admin, offers CloudFormation first, then Terraform and the CLI.
To create a [setup link](/docs/deployment-ux/setup-links) that offers only AWS:
```bash
alien onboard customer-1 --platforms aws
```
### CloudFormation [#cloudformation]
In the portal, the admin:
1. Opens **Review the stack** to read the **CloudFormation template**.
2. Picks a region under **Choose region**.
3. Clicks **Launch in AWS Console**. CloudFormation's quick-create page opens with every parameter filled in.
4. Acknowledges that CloudFormation creates IAM resources with custom names (`CAPABILITY_NAMED_IAM`) and creates the stack.
The stack reports that it started as soon as CloudFormation begins (a `DeploymentSetupStarted` custom resource), and registers the deployment when it finishes. The portal shows the stack name and region with a **View stack in AWS** link.
### Terraform [#terraform]
The admin clicks **Prepare Terraform**, saves the generated `main.tf` in an empty folder, and runs:
```bash
export TF_VAR_token=''
export TF_VAR_management_external_id=''
terraform init && terraform apply
```
`TF_VAR_token` is the **setup token**, the credential from the setup link that authorizes this one initial setup. `TF_VAR_management_external_id` is the customer's [ExternalId](#externalid).
The module uses the admin's `aws` provider configuration. `terraform plan` lists every resource before it is created.
### CLI [#cli]
The admin installs your CLI, saves the generated `deployment.toml`, and runs:
```bash
export ACMECTL_DEPLOYMENT_TOKEN=''
acmectl deploy --config deployment.toml
```
The CLI uses the standard AWS credential chain: environment variables, `AWS_PROFILE`, web identity, container credentials, or instance metadata. The region comes from `AWS_REGION`, `AWS_DEFAULT_REGION`, or the profile.
### Review the template first [#review-the-template-first]
To review the template with the customer's security team before anyone runs it:
```bash
alien render --format cloudformation --stack .alien/build/aws/stack.json > template.yaml
```
See [Setup packages](/docs/infrastructure/platforms/setup-packages#preview-a-package).
## What the initial setup creates [#what-the-initial-setup-creates]
| Kind | What |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Management role | `-management`, which Alien's manager assumes. See [Management role](#management-role). |
| Workload roles | One IAM role per [permission profile](/docs/guide/permissions), and an instance profile for compute machines |
| Network | With `NetworkMode` `create-new`: a VPC, subnets, internet and NAT gateways, route tables, and security groups. With `use-existing` or `use-default`, initial setup uses an existing VPC. |
| [Frozen resources](/docs/guide/frozen-and-live) | The resources only initial setup creates or changes, so Alien can't change them later. What the stack marks frozen, for example S3 buckets, KMS keys, or Aurora clusters |
After registration, the manager creates the live resources through the management role.
## Management role [#management-role]
The management role has a trust policy and a permission policy:
* **Trust policy.** Only Alien's managing role, named by exact ARN, can assume it. With an ExternalId set, every `sts:AssumeRole` call must also send that value.
* **Permission policy.** It allows the actions needed to create and update the stack's live resources, scoped to the deployment's resource name prefix. On frozen resources it gets only narrower management permissions, such as health checks. See [Frozen and live resources](/docs/guide/frozen-and-live).
The manager receives credentials that expire after one hour. Initial setup creates no access keys, and none are exchanged.
The customer can revoke access at any time by deleting the role or the stack.
### Permissions boundary [#permissions-boundary]
The management role can't turn its access into admin access to the account. Every IAM role it creates or changes must carry a **permissions boundary**: an IAM policy that caps what the role can do, whatever policies are attached to it.
Initial setup creates the boundary as the managed policy `-deployment-management-boundary` (in CloudFormation, the `ManagementPermissionsBoundary` resource). It allows:
* non-IAM actions on the deployment's own resources: names with the deployment's prefix, or resources tagged as owned by the stack
* a few account-level actions the deployment needs at runtime
It denies sharing and public exposure, such as bucket policies and ACLs, snapshot and image sharing, resource shares, and resource policies on Lambda, SQS, SNS, and other services.
The management role can't edit the boundary, remove it from a role, or write a role without it. Even a role with an admin policy attached is held to the boundary.
The boundary limits the roles the management role writes. It doesn't separate workloads within one deployment: each workload's own [permission profile](/docs/guide/permissions) still decides that.
[Custom permission sets](/docs/guide/permissions#custom-permission-sets) widen the boundary. The customer's admin reviews them at initial setup.
Deployments set up before the boundary keep working unchanged. To add it, the customer's admin [reruns initial setup](/docs/guide/frozen-and-live#changes-that-need-initial-setup-again) with CloudFormation, Terraform, or the setup CLI.
## ExternalId [#externalid]
Alien's managing role serves many customers. Without a per-customer secret in each trust policy, one customer's registration could name another customer's role: the confused-deputy problem. The ExternalId closes that gap.
* Alien generates one ExternalId per customer, stored on their **deployment group** (Alien's record of one customer): 32 to 128 letters and digits.
* The portal fills it in. In CloudFormation it is the `ManagementExternalId` parameter; in Terraform, the `management_external_id` variable. **Copy External ID** in the portal is for an admin who launches the template file by hand.
* Every AssumeRole of the customer's management roles sends it.
To require it, open project **Settings** → **Artifacts** → **CloudFormation** and turn on **Require AWS External ID**. It is on by default for new projects.
The customer's value is on their deployment group page under **AWS external ID**.
From the CLI:
```bash
alien projects packages update --file - <<'EOF'
{ "cloudformation": { "requireManagementExternalId": true } }
EOF
```
Over the API, send `PATCH /v1/projects/{id}` with `{"packagesConfigPatch": {"cloudformation": {"requireManagementExternalId": true}}}`. `GET /v1/deployment-groups/{id}/management-external-id` returns a customer's value.
With the setting on, templates built afterwards:
* Refuse an empty `ManagementExternalId` before creating anything.
* Accept only Alien's management roles as `ManagingRoleArn`.
Templates built earlier keep their behavior until rebuilt.
## How updates arrive [#how-updates-arrive]
Ongoing management on AWS uses **push**: Alien calls AWS APIs from outside the account, instead of a component inside it pulling releases. When a release reaches the deployment's [release channel](/docs/guide/releases), the manager assumes the management role and updates the live resources.
Nothing runs in the account to receive updates, and nothing connects into the customer's network. See [Push, pull, and air-gapped](/docs/infrastructure/platforms/deployment-models).
A release that changes a frozen resource or needs a new IAM permission waits until the admin updates the stack: **Update stack** → **Replace template** with the template URL the portal shows, or the same with Terraform or the CLI. See [Releases that need initial setup again](/docs/production/customer-setup#releases-that-need-initial-setup-again).
To run pull in an AWS account, deploy into an EKS cluster instead. See [Kubernetes](/docs/infrastructure/platforms/kubernetes).
## Remove it [#remove-it]
1. Delete the deployment from your side: **Delete deployment**, or `alien deployments delete customer-1/customer-1`.
2. Alien deletes the live resources, and the deployment shows **Teardown required**.
3. The admin deletes the stack:
```bash
aws cloudformation delete-stack --stack-name a3f9c21b0-customer-1 --region us-east-1
```
With Terraform, they run `terraform destroy` with the same `TF_VAR_token` and `TF_VAR_management_external_id`. With the CLI, `acmectl destroy`. See [Remove a deployment](/docs/production/customer-setup#remove-a-deployment).
## Find the stack name [#find-the-stack-name]
The portal names a new stack `a<8 random hex characters>-`, cut to 35 characters, for example `a3f9c21b0-customer-1`. The stack name is the prefix for the deployment's resource names and IAM scopes, and the random part keeps it unique in the account.
Search for the full stack name: a long deployment name is truncated, so searching for the deployment name can miss it.
```bash
aws cloudformation describe-stacks --region \
--query 'Stacks[].[StackName,StackStatus]' --output table
```
## Quotas [#quotas]
Before it creates anything, initial setup checks the AWS service quotas the stack needs, such as EC2 vCPUs, VPCs, Elastic IPs, NAT gateways per Availability Zone, and Lambda concurrency.
* A shortfall stops initial setup with the quota code, the current usage, and a link to request an increase.
* If the admin's credentials can't read a quota, that check reports a warning and initial setup continues.
* A passing check doesn't reserve quota: other workloads in the account can use it before provisioning finishes.
To check quotas while rendering a template, with credentials for the target account:
```bash
alien render --format cloudformation --stack .alien/build/aws/stack.json --check-quotas
```
When the customer accepts the risk, the CLI can turn a failed quota check into a warning for that deployment:
```bash
acmectl deploy --config deployment.toml --quota-warning-override L-1216C47A
```
The override is saved with the deployment. The cloud can still reject provisioning.
* Repeat the flag to give the complete replacement list. `--clear-quota-warning-overrides` removes them all. Omitting both keeps the saved list.
* Unknown quota codes, and codes from another cloud, are rejected.
* The same list can go in the config file as `quotaWarningOverrides = ["L-1216C47A"]`. Flags take precedence.
* `acmectl status --name --json` shows the saved list under `quotaWarningOverrides`.
## Limits [#limits]
* `EndpointAccess` (**Who can reach this deployment's endpoints?**) can't change after initial setup. Changing it needs a new deployment.
* `use-default` networking isn't offered when the stack needs explicit subnet IDs, for example for a sandbox.
* AWS builds default to `linux-arm64` (Graviton). Pass `alien release --targets linux-x64` to build for `x86_64`.
## CloudFormation parameters [#cloudformation-parameters]
The portal fills in every parameter. The ones an admin is most likely to change:
| Parameter | Values | Description |
| --------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `NetworkMode` | `create-new`, `use-existing`, `use-default` | Create a VPC, use one you have, or use the account's default VPC |
| `VpcCidr`, `AvailabilityZones` | CIDR, `1` to `3` | Only with `create-new`. Leave `VpcCidr` empty for the generated default. |
| `VpcId`, `PrivateSubnetIds`, `PublicSubnetIds`, `SecurityGroupIds` | IDs | Only with `use-existing`. `SecurityGroupIds` is optional. `PublicSubnetIds` can stay empty when no endpoint is internet-facing. |
| `EndpointAccess` | `internet`, `private` | Who can reach public endpoints. `private` keeps the load balancer internal. |
| `ManagementExternalId` | 32 to 128 letters and digits | The customer's ExternalId |
| `ContainerCpu`, `ContainerMemory` | Within the stack's range | CPU and memory per replica |
| `ContainerVolumeSizeGib` | `1` to `65536` | Persistent volume size per replica, in GiB. Volumes only grow: a smaller value in a stack update keeps the current size. |
| `ComputeMachine` | EC2 instance type, or empty | Machine type for a compute pool. Empty chooses a size automatically. |
| `ComputeMachines`, or `ComputeMin` and `ComputeMax` | Within the stack's range | A fixed machine count, or the autoscaling range |
| `DomainName`, `CertificateArn`, `DomainResource` | Hostname, ACM ARN, resource ID | Optional custom domain for a public endpoint |
`` and `` are the container and compute pool IDs in PascalCase, for example `ContainerApiVolumeSizeGib`. To change a parameter later, update the stack with new values.
The network settings are also Terraform variables (`network_mode`, `vpc_id`, `endpoint_access`, and so on) and CLI flags (`--network`, `--vpc-id`, `--endpoint-access`). See [Networking](/docs/guide/networking).
## Regions [#regions]
The portal lists the AWS regions that initial setup supports:
`us-east-1`, `us-east-2`, `us-west-1`, `us-west-2`, `ca-central-1`, `eu-west-1`, `eu-west-2`, `eu-west-3`, `eu-central-1`, `eu-north-1`, `ap-south-1`, `ap-southeast-1`, `ap-southeast-2`, `ap-northeast-1`, `ap-northeast-2`, `ap-northeast-3`, `sa-east-1`.
## Next [#next]
* [Google Cloud](/docs/infrastructure/platforms/gcp)
* [Azure](/docs/infrastructure/platforms/azure)
* [Follow a deployment](/docs/production/customer-setup): statuses, retries, and removal
* [Permissions](/docs/guide/permissions)
# Azure (/docs/infrastructure/platforms/azure)
An Azure deployment runs your stack in one resource group of your customer's Azure subscription: its Container Apps, VMs, storage accounts, and databases live there, inside the customer's boundary. The customer's admin sets it up once, and Alien rolls out every release after that, with no client secrets anywhere.
A deployment's life has two parts:
* **Initial setup**: the one-time step the admin runs with their own Azure credentials, using Terraform or the CLI.
* **Ongoing management**: everything after. Alien's **manager**, the service that holds your releases and deployments, rolls out each release without the admin.
Initial setup creates the deployment's **management identity**: here, a user-assigned managed identity the manager signs in as through a federated credential. It can assign only limited roles, so it can't grant itself or anyone else broader access ([Role assignments](#role-assignments)).
Initial setup also creates the [frozen resources](/docs/guide/frozen-and-live): the ones only initial setup may create or change, such as the storage account that holds the customer's data. The manager creates and updates the rest, the live resources.
How the admin reaches initial setup is your [Deployment UX](/docs/deployment-ux) choice: a link you send, a Deploy button in your product, or your own setup screen. What happens in Azure is the same either way, and this guide follows it from requirements to removal.
## What the customer needs [#what-the-customer-needs]
* **An Azure subscription.**
* **Permission to create resource groups, managed identities, custom role definitions, and role assignments** in the subscription. Owner on the subscription is the usual choice. Initial setup also registers the resource providers the stack needs.
* **Compute quota** for the stack. Initial setup checks it first. See [Quotas](#quotas).
## Set up [#set-up]
Each method runs a [setup package](/docs/infrastructure/platforms/setup-packages), which Alien builds from your release. The **deployment portal**, the branded setup page Alien hosts for the admin, offers Terraform first, then the CLI.
To create a [setup link](/docs/deployment-ux/setup-links) that offers only Azure:
```bash
alien onboard customer-1 --platforms azure
```
### Terraform [#terraform]
The admin clicks **Prepare Terraform**, saves the generated `main.tf` in an empty folder, and sets `azure_subscription_id`, `azure_resource_group_name`, and `azure_location` (default `eastus`). Then:
```bash
export TF_VAR_token=''
terraform init && terraform apply
```
`TF_VAR_token` is the **setup token**, the credential from the setup link that authorizes this one initial setup. The module uses the admin's `azurerm` credentials.
### CLI [#cli]
The admin installs your CLI, saves the generated `deployment.toml`, and runs:
```bash
export ACMECTL_DEPLOYMENT_TOKEN=''
acmectl deploy --config deployment.toml
```
The CLI uses Azure CLI or environment credentials.
### Review the module first [#review-the-module-first]
To review the module with the customer's security team first:
```bash
alien render --format terraform --target azure --stack .alien/build/azure/stack.json --output azure-setup
```
## What the initial setup creates [#what-the-initial-setup-creates]
* **A resource group**, named by the admin, that holds every resource of the deployment. Permissions are scoped to it, so other resource groups in the subscription are out of reach.
* **A user-assigned managed identity** with custom roles on that resource group.
* **A federated credential** on the identity, which trusts only Alien's manager.
* **Custom roles** for management and for each [permission profile](/docs/guide/permissions).
* **[Frozen resources](/docs/guide/frozen-and-live)**, the resources only initial setup creates or changes, so Alien can't change them later, such as storage accounts or Key Vault keys.
The customer can revoke access by deleting the federated credential, the identity, or the resource group.
## Role assignments [#role-assignments]
Deployments need role assignments at runtime, for example to give a worker access to a Service Bus queue. Initial setup keeps that ability narrow:
* Initial setup creates every custom role the deployment uses.
* The management identity can't create or edit role definitions.
* It can assign roles only under a condition that initial setup writes:
* Management roles go only to the management identity.
* Application roles go only to the deployment's own workload identities.
* It can't assign a built-in role such as Owner or Contributor, and it can't give itself an application role.
With [automatic setup](/docs/infrastructure/platforms/single-tenant#infrastructure-changes-without-rerunning-initial-setup) in your own subscription, Alien creates the workload identities after initial setup. Application roles can then go to any identity except the management identity. Role definitions stay pinned either way.
## How updates arrive [#how-updates-arrive]
Ongoing management on Azure uses **push**: Alien calls Azure APIs from outside the subscription, instead of a component inside it pulling releases. When a release reaches the deployment's [release channel](/docs/guide/releases), the manager signs in as the managed identity and updates the live resources through Azure APIs. Nothing connects into the customer's network.
If a release declares a permission that needs a new role, or changes a frozen resource, the rollout waits for the admin to run initial setup again: they update the module version and reapply Terraform, or rerun the CLI. See [Releases that need initial setup again](/docs/production/customer-setup#releases-that-need-initial-setup-again).
To run pull in an Azure subscription, deploy into an AKS cluster instead. See [Kubernetes](/docs/infrastructure/platforms/kubernetes).
## Remove it [#remove-it]
1. Delete the deployment from your side: **Delete deployment**, or `alien deployments delete customer-1/customer-1`.
2. Alien deletes the live resources, and the deployment shows **Teardown required**.
3. The admin runs `terraform destroy` in the setup folder with the same `TF_VAR_token`, or `acmectl destroy`.
See [Remove a deployment](/docs/production/customer-setup#remove-a-deployment).
## Quotas [#quotas]
Before it creates anything, initial setup checks the Azure compute quotas the stack needs, such as vCPUs per VM family in the region. A shortfall stops initial setup with the quota, current usage, and an increase link. See [AWS quotas](/docs/infrastructure/platforms/aws#quotas) for how warning overrides work.
## Regions [#regions]
The portal offers every Azure location in Alien's catalog.
## Next [#next]
* [AWS](/docs/infrastructure/platforms/aws)
* [Google Cloud](/docs/infrastructure/platforms/gcp)
* [Kubernetes](/docs/infrastructure/platforms/kubernetes)
# Push, pull, and air-gapped (/docs/infrastructure/platforms/deployment-models)
A deployment model is how releases and commands reach a deployment after its **initial setup**, the one-time step the customer's admin runs with their own credentials. Each deployment uses push, pull, or air-gapped, depending on the platform it's deployed on.
The models differ in who opens the connection. Alien's **manager** is the service that holds your releases and deployments.
* **Push**: the manager calls the cloud provider's APIs through a scoped **management identity** that initial setup created in the customer's account, such as an IAM role.
* **Pull**: a component inside the environment connects out to the manager over HTTPS. On Kubernetes it's the **Operator**; on Linux hosts, the machine agent.
* **Air-gapped**: there's no network path. An admin carries a folder of updates in and reports out.
The platform the admin deploys on sets the model, so one release can run push in one customer's AWS account and pull in another's Kubernetes cluster:
```bash
alien onboard customer-1 --platforms aws # push: Alien calls the cloud's APIs
alien onboard customer-2 --platforms kubernetes # pull: an Operator in the cluster connects out
alien onboard customer-4 --platforms kubernetes --airgapped # air-gapped: updates travel in a folder
```
In the [deployment portal](/docs/deployment-ux/deployment-portal), the setup page Alien hosts for the admin, the deployment's page shows the model under **Configuration** → **Delivery**: **Push**, **Pull**, or **Airgapped**.
## Who connects [#who-connects]
| | Push | Pull | Air-gapped |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| Who opens the connection | Alien's manager calls the cloud provider's APIs | The Operator, a small service that runs in the environment, connects out over HTTPS about every 30 seconds | Nobody. An admin carries a folder in and out |
| Identity Alien uses | A management identity that initial setup creates in the customer's account: IAM role (AWS), service account with workload identity federation (GCP), managed identity (Azure) | The Operator's own local identity, for example a Kubernetes service account | The Operator's local identity |
| Cross-account access | Yes, scoped to the deployment's resources | None | None |
| Inbound network access | None. Calls go to the cloud provider's API, not into the customer's network | None | None |
| Platforms | AWS, Google Cloud, Azure | Kubernetes, including EKS, GKE, and AKS in a customer's cloud account; Linux servers | Kubernetes |
In both push and pull, the customer's admin runs the initial setup once with their own credentials. After that, releases roll out without the admin. See [Deployment portal](/docs/deployment-ux/deployment-portal).
To run pull inside a customer's AWS, Google Cloud, or Azure account, the admin picks a Kubernetes cluster as the compute in the portal: a new or existing EKS, GKE, or AKS cluster. See [Kubernetes](/docs/infrastructure/platforms/kubernetes).
[Linux server](/docs/infrastructure/platforms/machines) deployments work like pull: an agent on each Linux host polls Alien over outbound HTTPS and keeps running the last plan it accepted if the connection drops.
## How commands arrive [#how-commands-arrive]
[Commands](/docs/guide/commands) are a separate axis from the deployment model. The target resource decides how a command is delivered:
| Command target | Push deployment | Pull deployment |
| --------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| `Worker` | The manager invokes it through the cloud: Lambda (AWS), Pub/Sub (Google Cloud), Service Bus (Azure) | The Operator leases it over its outbound connection |
| `Container`, `Daemon` | The workload's runtime polls the manager over outbound HTTPS | Same |
The Operator relays only Worker commands. Containers and Daemons reach the manager themselves over outbound HTTPS, through a receiver in your process. No command path needs an open port in the customer's network. See [How delivery works](/docs/guide/commands#how-delivery-works).
## Air-gapped [#air-gapped]
An air-gapped site has no network path to the manager. Releases go in, and status and logs come back, in one folder that the site's admin carries across the gap. The site runs the same Kubernetes chart as a connected deployment.
Air-gapped sites are onboarded from the CLI; the deployment portal doesn't offer them. Run `alien onboard customer-4 --platforms kubernetes --airgapped`. It registers the deployment and prints, for the site's admin:
* A sync token.
* Your manager's bundle key (`ed25519:...`). Confirm it with the admin over a separate channel.
* The command that starts the first sync.
The site shows up in `alien deployments ls` as `customer-4/customer-4`.
On a machine that can reach your manager, the admin downloads the update into a folder:
```bash
alien-deploy sync --token --manager
```
This creates `customer-4-sync/` and downloads the release the site should run. An update holds the application's images, the Operator's images, the Helm chart, and `alien-deploy` builds for the site. A manifest lists every file's SHA-256, and your manager signs it.
The token can only download this site's updates and send its reports. It stays on the online machine and is never written into the folder.
Inside the site, from the directory that holds the folder, the admin applies it:
```bash
alien-deploy sync \
--registry registry.internal/acme \
-f values.yaml \
--trusted-key ed25519:
```
`sync` then:
1. Verifies the signature and every file's checksum.
2. Pushes the images to the site's registry by digest.
3. Installs the chart and waits up to 10 minutes for the rollout.
4. Writes a report into the folder.
`values.yaml` maps the site's infrastructure the same way as a [connected deployment](/docs/infrastructure/platforms/kubernetes#connect-the-customers-infrastructure). Registry credentials (`--registry-username`, `--registry-password`) become the image pull secret for the Operator and your application.
The site remembers the bundle key and refuses any update signed with another key. The registry, namespace, values, and Kubernetes context are remembered on that machine too.
Later runs need no options. Online, `alien-deploy sync` sends the reports and downloads the next update; inside the site, it applies it. On a machine that reaches both the manager and the cluster, one run does both.
Updates carry only image layers the site doesn't have yet. For a site whose reports never come back, `alien-deploy sync --full` includes every layer. `alien-deploy sync --dry-run` shows the release change, images, and chart resources an update would change, without changing anything.
A report holds the deployment's status and the logs the Operator collected from your application's pods since the last report. Once it reaches your manager, the site's status and logs show up like any other deployment's.
The Operator buffers up to 512 MiB of telemetry until a report is confirmed received; when the buffer is full, it drops the oldest batches. Set `AIRGAP_TELEMETRY_BUFFER_BYTES` on the Operator to change the limit.
To return the site to the release it ran before the last sync:
```bash
alien-deploy rollback
```
The folder keeps the last three applied updates for rollback. Rollback writes a report, so your manager sees the change.
Every `alien release` that the site's channel receives reaches the site on its next sync. Release pins apply the same way.
The update includes the Operator version your manager runs, so the Operator updates with the application. See [`alien-deploy`](/docs/reference/cli/alien-deploy) for every `sync` flag.
## Choose a model [#choose-a-model]
| Choose | When |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Push | The customer is on AWS, Google Cloud, or Azure and accepts a scoped management identity. This is the default and needs nothing running in the environment besides your workloads. |
| Pull | The customer runs Kubernetes or plain Linux hosts, or their security team refuses any identity that an outside account can assume. In a cloud account, that means deploying into an EKS, GKE, or AKS cluster. |
| Air-gapped | The environment has no outbound path to the internet. Updates wait for someone to carry the folder. |
## Security review [#security-review]
Pull deployments use only outbound HTTPS, which most environments already allow. The usual objection in a security review is cross-account access: an identity in the customer's account that an outside account can assume.
Push deployments limit that access:
* The management identity can touch only the deployment's own resources, not the rest of the account.
* The customer's admin creates the [frozen resources](/docs/guide/frozen-and-live), such as the bucket or database that holds the customer's data. The management identity can't change or delete them.
* On AWS, each customer's role requires its own ExternalId. See [AWS](/docs/infrastructure/platforms/aws).
* The customer can review the generated template before initial setup and revoke access by deleting the identity.
When that is still too much, pull removes cross-account access: the Operator acts with the environment's own identity, and the customer controls its permissions with their own IAM or RBAC.
## Next [#next]
* [Follow a deployment](/docs/production/customer-setup): statuses, reruns of initial setup, and removal
* [Kubernetes](/docs/infrastructure/platforms/kubernetes): Helm install, then the Operator pulls updates
* [AWS](/docs/infrastructure/platforms/aws): the management role and ExternalId
* [Permissions](/docs/guide/permissions): what each identity can do
# Google Cloud (/docs/infrastructure/platforms/gcp)
A Google Cloud deployment runs your stack in your customer's Google Cloud project: its Cloud Run services, VMs, buckets, and databases live there, inside the customer's boundary. The customer's admin sets it up once, and Alien rolls out every release after that, with no service account keys anywhere.
A deployment's life has two parts:
* **Initial setup**: the one-time step the admin runs with their own Google credentials, using Google login, Terraform, or the CLI.
* **Ongoing management**: everything after. Alien's **manager**, the service that holds your releases and deployments, rolls out each release without the admin.
Initial setup creates the deployment's **management identity**: here, a service account the manager acts as through a workload identity pool the admin owns and can delete.
Initial setup also creates the [frozen resources](/docs/guide/frozen-and-live): the ones only initial setup may create or change, such as the bucket that holds the customer's data. The manager creates and updates the rest, the live resources.
How the admin reaches initial setup is your [Deployment UX](/docs/deployment-ux) choice: a link you send, a Deploy button in your product, or your own setup screen. What happens in Google Cloud is the same either way, and this guide follows it from requirements to removal.
## What the customer needs [#what-the-customer-needs]
* **A Google Cloud project.** A dedicated project is recommended. The project is the boundary: other projects in the organization are out of reach.
* **Permission to enable APIs and create service accounts, workload identity pools, IAM bindings, and the stack's frozen resources.** Project Owner is the usual choice.
* **Quotas** for the stack. Initial setup checks them first. See [Quotas](#quotas).
## Set up [#set-up]
Terraform and the CLI each run a [setup package](/docs/infrastructure/platforms/setup-packages), which Alien builds from your release. Google login needs none. The **deployment portal**, the branded setup page Alien hosts for the admin, offers Google login first, then Terraform and the CLI.
To create a [setup link](/docs/deployment-ux/setup-links) that offers only Google Cloud:
```bash
alien onboard customer-1 --platforms gcp
```
### Google Login [#google-login]
In the portal, the admin:
1. Clicks **Continue with Google** under **Connect Google Cloud** and approves access.
2. Picks the project under **Choose project**, or clicks **Enter project ID manually**.
3. Picks a region under **Choose region**.
4. Clicks **Start deployment**.
Initial setup runs with the admin's Google token. See [Google login](#google-login).
{/* SCREENSHOT: portal-gcp-google-login: /deploy#, Google Cloud + Google Login, signed in, project dropdown showing "customer-1-prod (customer-1-prod)", region us-central1 */}
### Terraform [#terraform]
The admin clicks **Prepare Terraform**, saves the generated `main.tf` in an empty folder, replaces ``, and runs:
```bash
export TF_VAR_token=''
terraform init && terraform apply
```
`TF_VAR_token` is the **setup token**, the credential from the setup link that authorizes this one initial setup. The module uses the admin's `google` provider credentials.
### CLI [#cli]
The admin installs your CLI, saves the generated `deployment.toml`, and runs:
```bash
export ACMECTL_DEPLOYMENT_TOKEN=''
acmectl deploy --config deployment.toml
```
The CLI uses Application Default Credentials.
### Review the module first [#review-the-module-first]
To review the module with the customer's security team first:
```bash
alien render --format terraform --target gcp --stack .alien/build/gcp/stack.json --output gcp-setup
```
## What the initial setup creates [#what-the-initial-setup-creates]
* **A management service account** with custom IAM roles generated from the stack's permission sets. Roles are scoped to the project or to individual resources, with IAM Conditions where Google Cloud supports them.
* **A workload identity pool and OIDC provider** owned by the customer. The provider accepts tokens only from the manager's issuer, only for one exact subject, and only for this deployment's audience.
* **One IAM binding:** `roles/iam.workloadIdentityUser` on the management service account, for that single federated subject.
* **One service account per [permission profile](/docs/guide/permissions)**, for your workloads.
* **The Google Cloud APIs** the stack needs, enabled.
* **[Frozen resources](/docs/guide/frozen-and-live)**, the resources only initial setup creates or changes, so Alien can't change them later, such as Cloud Storage buckets or Cloud KMS keys.
The customer can revoke access by deleting the pool, the binding, or the service account.
## Google login [#google-login-1]
Google login asks for the `cloud-platform` scope, because initial setup may enable APIs, create service accounts and IAM bindings, and create frozen resources.
Alien uses the token only for that one initial setup attempt. It never returns the token to the browser, and it deletes the session afterwards. Ongoing management uses the federated service account above, not the admin's token.
By default, Google's consent screen shows Alien's OAuth app. To show your own name, in the dashboard:
1. In a dedicated Google Cloud project, create an OAuth web client.
2. Open project **Settings** → **Deployment Portal** → **Google Sign-In**, set **OAuth app** to **Custom Google OAuth app**, and click **Copy redirect URI**. It has the form `https:///v1/gcp/oauth/callback`.
3. Add that redirect URI to the OAuth client in Google Cloud.
4. Enter the **Client ID** and **Client Secret**, and click **Save Portal**.
From the CLI, `alien projects portal google-sign-in` sets the same client. See [Brand the portal](/docs/deployment-ux/branding#google-sign-in).
Keep this app separate from the one you use for sign-in to your own product. Initial setup needs broad Google Cloud access; sign-in needs only identity scopes.
## How updates arrive [#how-updates-arrive]
Ongoing management on Google Cloud uses **push**: Alien calls Google Cloud APIs from outside the project, instead of a component inside it pulling releases. When a release reaches the deployment's [release channel](/docs/guide/releases), the manager takes the federated identity and updates the live resources through Google Cloud APIs. Nothing connects into the customer's network.
A release that changes a frozen resource or needs a new permission waits for the admin to run initial setup again: they sign in with Google again from the portal, reapply Terraform, or rerun the CLI. See [Releases that need initial setup again](/docs/production/customer-setup#releases-that-need-initial-setup-again).
To run pull in a Google Cloud project, deploy into a GKE cluster instead. See [Kubernetes](/docs/infrastructure/platforms/kubernetes).
## Remove it [#remove-it]
1. Delete the deployment from your side: **Delete deployment**, or `alien deployments delete customer-1/customer-1`.
2. Alien deletes the live resources, and the deployment shows **Teardown required**.
3. The admin finishes in the portal:
* Google login: **Finish uninstall** → **Continue with Google**. Alien removes what initial setup created, with that sign-in.
* Terraform: `terraform destroy` in the setup folder, with the same `TF_VAR_token`.
* CLI: `acmectl destroy`.
## Quotas [#quotas]
Before it creates anything, initial setup checks the Google Cloud quotas the stack needs, such as regional CPUs, custom IAM roles, and service accounts per project. A shortfall stops initial setup with the quota name, current usage, and an increase link.
The CLI accepts `--quota-warning-override` for a quota the customer chooses to accept. See [AWS quotas](/docs/infrastructure/platforms/aws#quotas) for how overrides work.
## Regions [#regions]
The portal offers every Google Cloud region in Alien's catalog.
## Next [#next]
* [Azure](/docs/infrastructure/platforms/azure)
* [AWS](/docs/infrastructure/platforms/aws)
* [Follow a deployment](/docs/production/customer-setup): statuses, retries, and removal
# Kubernetes (/docs/infrastructure/platforms/kubernetes)
A Kubernetes deployment runs your stack as pods in one namespace of your customer's cluster, on-prem or in EKS, GKE, or AKS. The customer's admin sets it up once with Helm, and every release after that arrives over outbound HTTPS: nobody runs `helm upgrade`, and nothing in the cluster accepts inbound connections.
The Helm chart runs the **Operator**, a small service that connects out to Alien's **manager** (the service that holds your releases and deployments) and deploys each release into the namespace. This is **pull**: a component inside the environment fetches releases, instead of Alien calling in.
A deployment's life has two parts:
* **Initial setup**: the one-time step the admin runs with their own cluster credentials, using Helm, or Terraform or CloudFormation to create a managed cluster first.
* **Ongoing management**: everything after. The Operator applies each release, sends logs, and runs commands, without the admin.
How the admin reaches initial setup is your [Deployment UX](/docs/deployment-ux) choice: a link you send, a Deploy button in your product, or your own setup screen. What happens in the cluster is the same either way, and this guide follows it from requirements to removal.
## What the customer needs [#what-the-customer-needs]
* **A cluster**, or a cloud account to create one in: EKS on AWS, GKE on Google Cloud, or AKS on Azure.
* **Helm 3.8 or later** (OCI charts). `--reset-then-reuse-values` needs Helm 3.14.
* **Rights to install the chart** into its namespace. Turning on the [Remote Operator](/docs/production/remote-operator) adds a custom resource definition, which may need cluster-admin approval.
* **Outbound HTTPS** from the cluster to Alien. Nothing needs to reach into the cluster.
* **The infrastructure your frozen resources map to**, such as an S3 bucket. See [Connect the customer's infrastructure](#connect-the-customers-infrastructure).
## Your stack [#your-stack]
Target the `kubernetes` platform. On other platforms, initial setup creates the [frozen resources](/docs/guide/frozen-and-live), the ones that hold the customer's data, like a bucket. On Kubernetes, each frozen resource maps to infrastructure the customer already runs, and the admin supplies it during initial setup.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
// Maps to a bucket the customer already runs, supplied at initial setup.
const files = new alien.Storage("files").build()
const api = new alien.Container("api")
.code({ type: "image", image: "ghcr.io/acme/api:1.4.0" })
.cpu(0.5)
.memory("512Mi")
.port(8080)
.link(files)
.permissions("api")
.build()
export default new alien.Stack("acme-files")
.platforms(["kubernetes"])
.add(files, "frozen")
.add(api, "live")
.permissions({
profiles: { api: { files: ["storage/data-read", "storage/data-write"] } },
})
.build()
```
Publish a release with `alien release`.
## Set up [#set-up]
The Helm chart, Terraform modules, and CloudFormation template are [setup packages](/docs/infrastructure/platforms/setup-packages), which Alien builds from your release. The **deployment portal**, the branded setup page Alien hosts for the admin, hands out the right one.
To create a [setup link](/docs/deployment-ux/setup-links) that offers only Kubernetes:
```bash
alien onboard customer-2 --platforms kubernetes
```
In the portal, the admin reaches Kubernetes from **On-Prem** or from a cloud:
* **On-Prem**, then **Compute** → **Kubernetes**: deploy into a cluster they operate, with Helm.
* **AWS**, **Google Cloud**, or **Azure**, then **Compute** → **Amazon EKS**, **Google Kubernetes Engine**, or **Azure Kubernetes Service**, and a **Cluster source**: create a new cluster or use an existing one.
### Helm on an existing cluster [#helm-on-an-existing-cluster]
1. **Prepare values**: save the generated `values.yaml`. It holds the deployment's token and name. Keep it for later upgrades.
2. **Install chart**: set the **Kubernetes namespace** and run the command from a shell with the target cluster's context.
```bash
helm install acme-files \
--namespace acme-files --create-namespace \
--values values.yaml
```
The portal fills in the chart reference and namespace. The Operator starts, registers the deployment, fetches the release, and deploys it. The deployment shows **Running** in a minute or two.
### New EKS, GKE, or AKS cluster [#new-eks-gke-or-aks-cluster]
A new EKS cluster is set up with CloudFormation (the default) or Terraform. A new GKE or AKS cluster is set up with Terraform.
* With **Install chart automatically** (Terraform only), `terraform apply` creates the cluster and installs the chart.
* With **Install chart separately**, or after the CloudFormation stack registers, the portal shows **Connect your cluster**: save `values.yaml`, then install the chart with `kubectl` pointed at the new cluster.
### Existing EKS, GKE, or AKS cluster [#existing-eks-gke-or-aks-cluster]
Terraform only. The admin sets the cluster in the generated `main.tf` (`eks_cluster_name`, `gke_cluster_name` and `gke_cluster_location`, or `aks_cluster_name` and `aks_cluster_resource_group_name`), then runs `terraform init && terraform apply` with `TF_VAR_token` set.
### What initial setup creates [#what-initial-setup-creates]
* **The Operator**, in the deployment's namespace, registered with Alien under the token in `values.yaml`.
* **Your workloads**, which the Operator creates in that namespace from the release.
* **A new cluster**, only when the admin chose to create one with Terraform or CloudFormation.
Frozen resources aren't created: they map to the customer's infrastructure. See [Connect the customer's infrastructure](#connect-the-customers-infrastructure).
### Chart versions [#chart-versions]
Each release gets a new chart version. Alien serves the images and the chart, so you don't need a registry that customers can reach.
## Connect the customer's infrastructure [#connect-the-customers-infrastructure]
The `infrastructure` map in the values file binds each frozen resource to something the cluster already has. For storage, that is S3 or any S3-compatible store:
```yaml title="values.yaml"
infrastructure:
files:
type: storage
service: s3
bucketName: customer-2-files
# For S3-compatible stores (MinIO, Ceph). Omit these three for Amazon S3
# with the pod's own AWS identity.
endpoint: https://minio.customer-2.internal
accessKeyId:
secretAccessKey:
```
To keep credentials out of values files, store the map as JSON under the key `external-bindings.json` in a Kubernetes Secret, and set `infrastructureExistingSecret` to the Secret's name.
## How updates arrive [#how-updates-arrive]
Ongoing management on Kubernetes uses pull: the Operator connects out to Alien and applies what it finds on the deployment's [release channel](/docs/guide/releases). Nobody runs `helm upgrade` for a release. See [Push, pull, and air-gapped](/docs/infrastructure/platforms/deployment-models).
* **Releases.** Every `alien release` on the deployment's channel reaches the cluster on the Operator's next sync, about every 30 seconds. Use channels and pins to stage rollouts. See [Releases](/docs/guide/releases).
* **The Operator.** It updates itself to the version Alien serves. Set `runtime.image.selfUpdate=false` to require a `helm upgrade` instead.
* **Images.** Pods pull through Alien with the deployment's token. The cluster needs no credentials for your registry.
* **Logs.** The Operator collects your containers' logs and OpenTelemetry data and sends them out over the same connection.
* **Commands.** [Commands](/docs/guide/commands) reach your containers through the Operator.
* **Requests.** Your backend can call HTTP ports that a container declares with `.tunnel(port)`. See [Tunnels](#tunnels).
* **Changes that need initial setup again.** A release that needs a new frozen resource or permission can't be applied by a `helm upgrade`. The portal tells the admin to keep the current release and contact you, so keep such changes out of releases for Helm installs.
* **Remote Operator.** The chart can also run the [Remote Operator](/docs/production/remote-operator) for operations and access requests. It's off by default. Turning it on may need cluster-admin approval, because it adds a custom resource definition for access requests.
## Tunnels [#tunnels]
A tunnel lets your backend call an HTTP service inside the customer's cluster without anything opening inbound. The Operator holds an outbound WebSocket connection to the manager. Requests you send to the manager travel over it to the container.
Declare the port your backend may reach. Only declared ports are reachable, and nothing is exposed on the customer's network:
```ts title="alien.ts"
const api = new alien.Container("api")
.code({ type: "image", image: "ghcr.io/acme/api:1.4.0" })
.port(8080)
.tunnel(8080)
.build()
```
Create a token that can call tunnels and nothing else, then call the container:
```bash
alien tokens create --tunnel # every deployment
alien tokens create --tunnel --customer customer-2 # one customer's deployments
curl "https:///v1/deployments/customer-2/tunnels/api/objects?prefix=reports/" \
-H "Proxy-Authorization: Bearer ax_tunnel_..." \
-H "Authorization: Bearer "
```
* The path is `/v1/deployments/{deployment}/tunnels/{container}/{path}`. `{deployment}` is the deployment's ID, or its name when that is unique. Everything after the container name, including the query string, is the path the container receives.
* `Proxy-Authorization` authenticates you to the manager, which removes it before forwarding. `Authorization` and other end-to-end headers reach the container unchanged, so your application keeps its own authentication.
* The container sees `X-Forwarded-Proto` and `X-Forwarded-Host` for the manager's public URL.
* Bodies stream in both directions with flow control, and many requests share one connection. Tunnels carry HTTP requests only: WebSocket upgrades and raw TCP are not forwarded.
* A tunnel token can't read releases, deployments, or logs. `alien tokens revoke ` takes effect on the next request.
Tunnels are served by a manager you run with `alien serve` or the `alien-manager` image.
Errors from the tunnel itself, rather than from your container, carry an `alien-tunnel-error` header:
| Status | Code | Meaning |
| ------ | ----------------------------- | -------------------------------------------------------------------------------------- |
| 401 | | Missing or invalid `Proxy-Authorization` |
| 403 | | The token can't call this deployment's tunnels |
| 404 | `TUNNEL_TARGET_NOT_FOUND` | The container declares no tunnel |
| 502 | `TUNNEL_UPSTREAM_UNAVAILABLE` | The container didn't answer |
| 502 | `TUNNEL_REQUEST_FAILED` | The connection to the Operator broke mid-request |
| 503 | `TUNNEL_NOT_CONNECTED` | The deployment's Operator isn't connected, or initial setup set `tunnel.enabled=false` |
The Operator reconnects on its own after network interruptions and manager restarts.
If a proxy or load balancer sits in front of the manager, allow WebSocket upgrades on `/v1/tunnel/connect`. Set idle timeouts well above 20 seconds, the interval of the tunnel's keepalive pings.
## Remove it [#remove-it]
Delete the deployment from your side: **Delete deployment**, or `alien deployments delete customer-2/customer-2`. The deployment then shows **Teardown required** until the admin uninstalls the chart:
```bash
helm uninstall acme-files --namespace acme-files
```
A cleanup job removes the workloads the Operator created.
* Persistent volume claims stay unless `runtime.cleanup.onUninstall.deletePersistentVolumeClaims=true`.
* Infrastructure mapped in `values.yaml`, like the bucket, is never touched.
* A cluster that initial setup created with Terraform is removed with `terraform destroy`.
## Chart values [#chart-values]
| Value | Description |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `management.token` | The deployment's token from the portal |
| `management.existingSecret` | Read the token from a Secret instead |
| `management.name` | Deployment name shown in the dashboard |
| `infrastructure` | Frozen resources mapped to the cluster's infrastructure |
| `infrastructureExistingSecret` | The same map, from a Secret |
| `runtime.image.selfUpdate` | Follow the Operator version Alien serves. Default `true`. |
| `logCollector.enabled` | Collect container logs. Default `false`. Set `logCollector.mode` to `podApi` (reads selected Pod logs through the Kubernetes API, no DaemonSet) or `nodeAgent` (a Fluent Bit DaemonSet). |
| `tunnel.enabled` | Accept [tunnel](#tunnels) requests from the manager. Default `true`. |
| `airgapped.enabled` | Take releases from sync bundles instead. See [Air-gapped](/docs/infrastructure/platforms/deployment-models#air-gapped). |
`helm show values ` lists the rest, including resources, scheduling, network policy, and security context.
To change values later:
```bash
helm upgrade acme-files \
--namespace acme-files --reset-then-reuse-values --values values.yaml
```
## Next [#next]
* [Push, pull, and air-gapped](/docs/infrastructure/platforms/deployment-models): how pull and air-gapped deployments get releases
* [Linux servers](/docs/infrastructure/platforms/machines): hosts without Kubernetes
* [Remote Operator](/docs/production/remote-operator): operate the deployment
# Linux servers (/docs/infrastructure/platforms/machines)
A Linux servers deployment runs your software on your customer's own Linux hosts: on-prem hardware, bare metal, or VMs in any provider, with no Kubernetes and no cloud account. Alien calls this platform **Machines**. The customer's admin runs one command on each host, and every release after that arrives without them.
Each host runs a **machine agent**, a small service that connects out to Alien over HTTPS and runs the workloads Alien places on it. This is **pull**: the agent fetches each release, and nothing connects into the customer's network.
A deployment's life has two parts:
* **Initial setup**: the admin creates the deployment and runs a join command on each host, with root or `sudo`.
* **Ongoing management**: everything after. Alien places your workloads on the joined hosts and rolls out each release without the admin. Hosts keep running if the connection drops.
How the admin reaches initial setup is your [Deployment UX](/docs/deployment-ux) choice: a link you send, a Deploy button in your product, or your own setup screen. What happens on the hosts is the same either way, and this guide follows it from requirements to removal.
Machines fit software that needs the host itself: sandboxes with nested virtualization, high-throughput processing on local disks, or anything next to existing on-prem systems.
For employee laptops and workstations, the `local` platform runs a [Daemon](/docs/infrastructure/daemon), a long-running process, under supervision on the device.
## What the customer needs [#what-the-customer-needs]
* Linux hosts with systemd, on `x86_64` or `arm64`, with root or `sudo`. See [Host requirements](#host-requirements).
* UDP `51820` open between the hosts, and outbound HTTPS to Alien.
* No Kubernetes and no cloud account.
## Your stack [#your-stack]
Declare a `ComputeCluster` pool with the hardware each host must have, and the workloads that run on it. Machines runs `Daemon` resources and stateless `Container` resources.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const hosts = new alien.ComputeCluster("hosts")
.pool("general", {
requirements: {
cpu: 4,
memory: "16Gi",
ephemeralStorage: "100Gi",
dataDisk: { size: "200Gi" },
},
scale: { type: "fixed", machines: 3 },
})
.build()
const agent = new alien.Daemon("agent")
.code({ type: "image", image: "ghcr.io/acme/agent:2.1.0" })
.cluster("hosts")
.cpu(1)
.memory("1Gi")
.build()
export default new alien.Stack("acme-edge")
.platforms(["machines"])
.add(hosts, "frozen")
.add(agent, "live")
.build()
```
On Machines, a pool is a label. Each host picks one when it joins, and Alien doesn't add or remove hosts.
* `dataDisk` requires an extra empty disk on every host.
* The build rejects Worker, Build, ServiceAccount, and stateful Container resources for `machines`, and public endpoints on Containers.
* Only a Daemon with `runtime.networkMode: "host"` can have a public endpoint. It serves HTTPS on port 443 of the host itself.
## Create the deployment [#create-the-deployment]
For a customer, create a [setup link](/docs/deployment-ux/setup-links) that allows **On-Prem**. The admin opens it in the **deployment portal**, the branded setup page Alien hosts for them:
```bash
alien onboard customer-3 --platforms machines
```
In the [portal](/docs/deployment-ux/deployment-portal), the admin:
1. Picks **On-Prem**, then **Compute** → **Your machines**.
2. Clicks **Set up machines**.
3. Copies the join command from the **Connect machines** panel. **Generate new command** issues a fresh one.
For your own hosts, in the dashboard, open **Deployments** → **Deploy release** → **Deploy to your environment**, then **On-Prem** → **Your machines**.
{/* SCREENSHOT: portal-connect-machines: /deploy#?deployment=, Connect machines panel with the join command and two hosts listed as Ready */}
From the CLI:
```bash
alien deploy --name eu-edge --machines
```
This creates the deployment and prints the join command. Until the first host joins, the deployment status is **Waiting for machines**.
## Join a host [#join-a-host]
Run the same command on each Linux host:
```bash
curl -fsSL '' | sudo bash -s -- join --token ''
```
The script installs your setup CLI, a [setup package](/docs/infrastructure/platforms/setup-packages#cli) Alien builds under your command name (here `acmectl`), and runs `join`, which:
1. Checks the host.
2. Installs the machine agent as the `alien-machine.service` systemd unit.
3. Waits up to 120 seconds for the host to register.
| `join` flag | Description |
| ------------------------- | ---------------------------------------------------------------------------- |
| `--token`, `--token-file` | The join token (`aj1_...`) |
| `--capacity-group` | The pool this host joins. Defaults to the only pool, or `general`. |
| `--zone` | A physical or logical zone label for the host |
| `--network-interface` | The interface to advertise to other hosts |
| `--public-ip` | The IP that public endpoint DNS points to. A VPN-reachable private IP works. |
| `--wireguard-endpoint` | The `IPv4:port` other hosts use to reach this one |
| `--dry-run` | Print the join plan without installing anything |
Join tokens can carry an expiry and a join limit. **Manage join tokens** lists them, and **Rotate all join tokens** replaces them. From the CLI:
```bash
alien deployments join-tokens list /eu-edge
alien deployments join-tokens create /eu-edge
alien deployments join-tokens rotate /eu-edge
alien deployments join-tokens revoke /eu-edge
```
`create` and `rotate` print the join command with the new token. The token is shown only once.
Rotating stops new joins. Hosts that already joined stay connected.
## Host requirements [#host-requirements]
Join checks these before it installs anything:
* Linux with systemd, on `x86_64` or `arm64`, with root or `sudo`.
* `containerd`, `wg` (WireGuard tools), and `nft` (nftables). On apt-based systems join installs them; on dnf-based systems it prints the install commands.
* Kernel WireGuard, with UDP `51820` open between the hosts.
* The pool's capacity: CPU at least the requirement, memory at least 90% of it, and `/var/lib/containerd` at least 85% of `ephemeralStorage`.
* An empty, unmounted disk of at least `dataDisk.size`, when the pool declares one.
* Outbound HTTPS to Alien.
Join enables IP forwarding. Hosts reach each other over a WireGuard mesh, and workloads find each other at `.svc`.
## How updates arrive [#how-updates-arrive]
Ongoing management on Machines uses pull. The machine agent polls Alien over outbound HTTPS and applies each release on the deployment's [release channel](/docs/guide/releases). Nothing connects into the customer's network.
The machine agent also updates itself:
* The agent checks each new version against its SHA-256 and starts it once before it replaces the old one.
* Hosts update one at a time. Each must stay healthy for 5 minutes before the next one starts.
* An agent that fails to start three times reverts to the previous version.
* Your workloads and public endpoints keep running during the agent update.
## Offline behavior [#offline-behavior]
The agent polls Alien every 5 seconds. If it can't reach Alien, the host keeps running the last plan it accepted:
* Running workloads continue.
* Crashed processes restart.
* Workloads come back after a reboot.
A plan is bound to the host that received it, so a cloned disk refuses to start it.
## Machines in the dashboard [#machines-in-the-dashboard]
Open **Manage machines** on the deployment page. Each host appears under **Connected machines** with its replicas, CPU, memory, storage, network, and **Machine agent** version (marked **outdated** when behind).
A host is **Ready**, **Unhealthy**, **Offline**, **Draining**, or **Drained**.
To take a host out of service:
1. Click **Drain machine** and set a deadline (default 6 hours). Alien moves work off the host. **Force drain** skips the wait.
2. Click **Remove machine**. **Force remove** removes a host that is unreachable.
On the host itself, `leave` stops the agent and removes it from the host. `--purge` also removes its identity and state:
```bash
sudo acmectl leave --purge
```
From the CLI, `alien deployments machines /eu-edge --json` lists the hosts and their state. The same steps take the machine ID it shows:
```bash
alien deployments drain-machine /eu-edge --deadline 6h
alien deployments cancel-machine-drain /eu-edge
alien deployments remove-machine /eu-edge
```
`drain-machine --force` skips the wait. `--force` and `remove-machine` ask for confirmation; pass `--yes` in scripts.
To run a command on a host for debugging:
```bash
alien debug exec eu-edge --machine -- df -h
```
## Remove it [#remove-it]
Delete the deployment (**Delete deployment**, or `alien deployments delete /eu-edge`), then remove the agent from each host with `sudo acmectl leave --purge`.
## Limits [#limits]
* Designed for clusters of up to about 10 hosts.
* No GPU workloads on Machines.
* Public endpoints use A records for the hosts' IPs, or a CNAME to the customer's own load balancer. They're served only by host-network Daemons (`MACHINES_CONTAINER_PUBLIC_ENDPOINT`, `MACHINES_DAEMON_PUBLIC_ENDPOINT`).
## Next [#next]
* [Kubernetes](/docs/infrastructure/platforms/kubernetes): for customers with a cluster
* [Push, pull, and air-gapped](/docs/infrastructure/platforms/deployment-models): how updates reach each kind of deployment
# Setup packages (/docs/infrastructure/platforms/setup-packages)
A setup package is what your customer's admin runs for the **initial setup**, the one-time step in their environment with their own credentials. Alien builds the packages from each release in formats admins already know: a CloudFormation template, Terraform modules, a Helm chart, and a CLI. You write none of them.
Each package:
1. Creates the deployment's **management identity**, the scoped identity Alien uses for ongoing management afterwards, such as an IAM role on AWS.
2. Creates its [frozen resources](/docs/guide/frozen-and-live), the ones only initial setup may create or change, and one identity per [permission profile](/docs/guide/permissions).
3. Registers the deployment with Alien.
The admin doesn't pick a package by name. The **deployment portal**, the branded setup page Alien hosts for them, hands out the one that matches the **Install method** they choose. With your own setup screen ([Deployment UX](/docs/deployment-ux)), your product hands it out instead.
| Package | Built when the release targets | Install method in the portal |
| ----------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------- |
| CloudFormation template | AWS | CloudFormation (AWS, and a new EKS cluster) |
| Terraform modules | AWS, Google Cloud, or Azure; or Kubernetes with infrastructure the customer supplies | Terraform |
| Helm chart and Operator image | Kubernetes | Helm, and the chart step of a new cluster |
| CLI | Any platform | CLI, and the Linux servers join command |
Google login needs no package. The admin signs in with Google in the portal, and Alien runs initial setup with that token once.
The Helm chart works differently: it installs the **Operator**, a service in the cluster that pulls each release over outbound HTTPS, instead of a management identity. See [Kubernetes](/docs/infrastructure/platforms/kubernetes).
To see what an admin will launch before you send any link, render the CloudFormation template on your machine. See [Preview a package](#preview-a-package).
```bash
alien render --format cloudformation --stack .alien/build/aws/stack.json > template.yaml
```
## When packages are built [#when-packages-are-built]
Alien builds packages on its own. A new set is built when:
* A release is promoted to a channel, which `alien release` does for `production`.
* A channel moves to another release.
* You change the packages settings.
* Alien ships a new version of the generators.
* You click **Rebuild**.
A build is skipped when nothing that affects it changed. Building takes a few minutes.
Until a method's package is ready, the portal doesn't offer it. A link created meanwhile still works: the portal shows **Building deployment packages** and continues when one method is ready.
To see the packages in the dashboard, open **Artifacts** in the sidebar. Each card shows the package's version and health: **Healthy**, **Building**, **Queued**, **Failed**, or **Not built**. Pick a release channel at the top; **Rebuild** builds every package again for the `production` channel's release.
From the CLI:
```bash
alien packages ls
alien packages get
alien packages rebuild
alien packages cancel
alien packages download -o ./packages
```
`rebuild` builds every package again and cancels the ones still pending or building. `rebuild` and `cancel` ask for confirmation; pass `--yes` in scripts.
Over the API, list packages and rebuild them:
```bash
curl "https://api.alien.dev/v1/packages?project=docproc" \
-H "Authorization: Bearer $ALIEN_API_KEY"
curl -X POST "https://api.alien.dev/v1/packages/rebuild" \
-H "Authorization: Bearer $ALIEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"project": "docproc"}'
```
The TypeScript client has `alien.packages.list`, `get`, `rebuild`, and `cancel`.
## Versions [#versions]
Each package type has its own version sequence: `1.0.0`, `1.0.1`, and so on, one step per build. A package version is not your release version. Each package records the release it was built from.
Old versions stay available, so a Terraform lock file or a pinned CLI keeps working.
The portal always hands out the package built for the release the deployment is set up with. When the admin reruns initial setup, it hands out the package for the new release.
## Where they are hosted [#where-they-are-hosted]
Packages are served from `pkg.alien.dev`, or from `pkg.` once you set a [custom domain](/docs/deployment-ux/branding#your-domain). Downloads need no credentials: the setup token, not the package, authorizes initial setup.
| Package | Where |
| -------------- | --------------------------------------------------------------------------------------------------- |
| CLI | `https://pkg.///install.sh` (and `install.ps1`), with `?version=` |
| Terraform | A Terraform registry at `pkg.`: module source `pkg.///` |
| Helm | An OCI chart. The portal shows the chart reference. |
| CloudFormation | An S3 URL, because CloudFormation only launches templates from S3 |
## Customize the packages [#customize-the-packages]
In the dashboard, open project **Settings** → **Artifacts**. Turn each package on or off, and set:
| Package | Settings |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **CLI** | **Command name** (for example `acmectl`), **Display name**, and under **Advanced** the **Platforms** to build: macOS Apple silicon, Linux x64, Linux ARM64, Windows x64 |
| **Helm** | **Chart name**, **Description**, **Collect logs** (the default for new deployments), **Default collection mode** (Pod API or Node agent), **Runtime storage capacity** |
| **Operator image** | **Image name**, **Display name**, and under **Advanced** a **Resource prefix**, **Environment prefix**, and **Kubernetes label prefix** |
| **Terraform** | **Display name** |
| **CloudFormation** | **Display name**, **Require AWS External ID** |
Helm needs the Operator image, so they turn on and off together. Click **Save changes**. Saving rebuilds the packages for the current production release.
From the CLI, read the current settings:
```bash
alien projects packages get --json > packages.json
```
Edit the JSON, then send back only what changes:
```bash
cat > patch.json <<'EOF'
{
"cli": { "enabled": true, "name": "acmectl", "displayName": "Acme Deployment CLI" },
"helm": { "logCollector": { "enabled": true, "mode": "podApi" } }
}
EOF
alien projects packages update --file patch.json
```
Fields you leave out keep their values. Set a section to `null` to stop building that package.
Over the API, send the same patch as `packagesConfigPatch`:
```bash
curl -X PATCH "https://api.alien.dev/v1/projects/docproc" \
-H "Authorization: Bearer $ALIEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"packagesConfigPatch": {"cli": {"enabled": true, "name": "acmectl", "displayName": "Acme Deployment CLI"}}}'
```
New projects build every package. The default CLI name is your project name plus `ctl` (`docproc` becomes `docprocctl`, `doc-proc` becomes `doc-proc-ctl`), and the default chart name is `-operator`.
The Helm section also takes `setupResources`, available only through the CLI and the API.
It makes the chart create a Secret with a random token (`tokenSecret`) and grant a permission profile cluster-wide read-only access to a fixed list of Kubernetes resources through a ClusterRole (`workloadReadAccess`). See [Build a Kubernetes agent](/docs/examples/kubernetes-agent#set-up-the-chart) for every rule.
## CloudFormation [#cloudformation]
The template creates the management role, the frozen resources, and the workload roles.
It takes the deployment's settings as parameters: network mode, endpoint access, sizes, and the ExternalId. The portal fills them in. See [AWS](/docs/infrastructure/platforms/aws#cloudformation-parameters).
The stack is launched with the deployment token of a deployment Alien created first. It registers that deployment when it finishes, and uses the same token for updates and deletion. See [Link lifetime](/docs/deployment-ux/setup-links#link-lifetime).
## Terraform [#terraform]
There is a module per target: `aws`, `gcp`, `azure`, and `eks`, `gke`, `aks` for Kubernetes on a managed cluster. A provider from the same registry registers the deployment. The portal generates the admin's `main.tf`; abbreviated, it looks like this:
```hcl title="main.tf"
variable "token" {
type = string
sensitive = true
}
# The portal also pins the registration provider in a terraform { required_providers } block.
module "deployment" {
source = "pkg.alien.dev/acme/docproc/aws"
version = "1.0.4"
name = "customer-1"
token = var.token
management_url = ""
aws_region = "us-east-1"
# The portal fills in the management role and the deployment's settings.
}
output "deployment_id" {
value = module.deployment.deployment_id
}
```
`token` and `management_url` have no defaults; the portal's snippet supplies both. `terraform plan` stops on a `management_url` that isn't an `http(s)://` URL.
`name` defaults to a slug of the application's display name, or of the stack ID, and must be a valid deployment name: 2 to 100 lowercase letters and digits in hyphen-joined groups, not starting with `ag-`.
`terraform plan` lists every resource before the admin applies it. The modules need Terraform 1.5 or later (1.9 for EKS) and only the providers that target uses.
## Helm [#helm]
The chart runs the Operator, which registers the deployment and then pulls each release. The chart version equals the package version, and the Operator image is pinned by digest. Values are described on [Kubernetes](/docs/infrastructure/platforms/kubernetes#chart-values).
## CLI [#cli]
The CLI is `alien-deploy` under your **Command name**. Its help text uses your **Display name**. Install it with:
```bash
curl -fsSL "https://pkg.alien.dev/acme/docproc/install.sh" | bash
```
On Windows: `irm "https://pkg.alien.dev/acme/docproc/install.ps1" | iex`.
The script checks the binary's SHA-256 and installs to `/usr/local/bin` when writable, else `~/.local/bin`.
| Command | What it does |
| ---------------------------------- | -------------------------------------------------------------------------------------------------- |
| `acmectl deploy` | Set up a deployment, or rerun initial setup with `--setup-update` |
| `acmectl status --name ` | Show a deployment's status |
| `acmectl list` | List the deployments the token can see |
| `acmectl destroy` | Remove a deployment and what initial setup created |
| `acmectl join`, `acmectl leave` | Add or remove a Linux host. See [Linux servers](/docs/infrastructure/platforms/machines). |
| `acmectl sync`, `acmectl rollback` | Air-gapped updates. See [Air-gapped](/docs/infrastructure/platforms/deployment-models#air-gapped). |
The token comes from `--token`, `--token-file`, or the `ACMECTL_DEPLOYMENT_TOKEN` environment variable (your command name in capitals, plus `_DEPLOYMENT_TOKEN`). See [`alien-deploy`](/docs/reference/cli/alien-deploy) for every flag.
## Preview a package [#preview-a-package]
`alien render` generates a template, module, or chart from your stack on your machine, so you or the customer's security team can review it:
```bash
alien render --format cloudformation --stack .alien/build/aws/stack.json --output review/
alien render --format terraform --target gcp --stack .alien/build/gcp/stack.json --output review/gcp
alien render --format helm --stack .alien/build/kubernetes/stack.json --output review/helm
```
| Flag | Values |
| ---------------- | ------------------------------------------------------------------------------------------------------------ |
| `--format` | `cloudformation`, `terraform`, `helm` |
| `--target` | `aws`, `gcp`, `azure`, `eks`, `gke`, `aks`. Required for Terraform. |
| `--stack` | `alien.ts`, `alien.js`, a `.json` stack, or a directory with one of them |
| `--output` | A directory. Without it, the result goes to stdout. |
| `--check-quotas` | Check AWS quotas with the credentials in your environment. CloudFormation, or Terraform with `--target aws`. |
| `--json` | CloudFormation as JSON instead of YAML |
The rendered files show the same resources and permissions as the hosted packages, but they are not byte-for-byte the same: the hosted Terraform module adds the registration provider, and the hosted chart uses your chart name.
## Next [#next]
* [Deployment portal](/docs/deployment-ux/deployment-portal): which package the portal hands the admin
* [Brand the portal](/docs/deployment-ux/branding): your domain and names on every package
* [AWS](/docs/infrastructure/platforms/aws), [Google Cloud](/docs/infrastructure/platforms/gcp), [Azure](/docs/infrastructure/platforms/azure), [Kubernetes](/docs/infrastructure/platforms/kubernetes)
# Single-tenant in your cloud (/docs/infrastructure/platforms/single-tenant)
A single-tenant deployment is a copy of your stack dedicated to one customer and running in **your** cloud account, not theirs. Customers ask for one when they need isolation, a specific region, or their own encryption key. It's an ordinary Alien deployment: same `alien.ts`, same releases, same dashboard.
Everything else in your product can stay shared. Only the part that holds the customer's data, or needs their region, gets a dedicated copy that Alien deploys and updates. This is the [control plane ⇄ data plane](/docs/guide/design/control-plane-data-plane) design, with both halves in your own account.
A deployment's life has two parts, and here you run both:
* **Initial setup**: the one-time step, which you run with your own cloud credentials. No customer admin runs it, so it needs no [Deployment UX](/docs/deployment-ux).
* **Ongoing management**: everything after. Alien's **manager**, the service that holds your releases and deployments, rolls out each release.
The account's platform guide ([AWS](/docs/infrastructure/platforms/aws), [Google Cloud](/docs/infrastructure/platforms/gcp), [Azure](/docs/infrastructure/platforms/azure)) describes what initial setup creates there. What differs when the account is yours:
* You deploy from the dashboard or the CLI.
* Releases can change infrastructure without rerunning initial setup.
* The customer can still hold the encryption key, on AWS.
## Why dedicate one [#why-dedicate-one]
In a shared deployment, one set of servers holds credentials that can read every customer's data, and only a `WHERE tenant_id = $1` clause keeps customers apart. A dedicated deployment changes that:
* **A break-in reaches one customer.** Each deployment has its own database, storage, and credentials.
* **Data stays in the customer's region.** Put customer-2 in India and customer-4 in the UK.
* **No noisy neighbors.** One customer's load doesn't slow another's requests.
* **It can use the customer's key.** See [Customer-managed key on AWS](#customer-managed-key-on-aws).
Run by hand, single-tenant copies drift: a patch reaches some customers months late. With Alien, each release reaches every dedicated deployment on its [release channel](/docs/guide/releases), and you roll out, pin, and roll back per customer.
It's also a good first step when you don't yet know which part of your product belongs in the customer's account: dedicate it first, then decide.
## Deploy one [#deploy-one]
In the dashboard:
1. Open **Deployments** and click **Deploy release**.
2. Choose **Deploy to your environment** and enter the **Environment** name, for example `customer-1-eu`.
3. Pick the **Platform**, then **Configure** (region, compute, network), the **Install method**, and any **Inputs**.
4. Run initial setup with your own cloud credentials: **Launch in AWS Console**, **Continue with Google**, **Prepare Terraform**, or the CLI command shown.
The dashboard then opens the new deployment.
From the CLI, with credentials for your AWS account in the environment:
```bash
alien deploy --name customer-1-eu --platform aws --deployment-group customer-1
```
`--deployment-group` places the deployment under the customer's **deployment group**, Alien's record of one customer, which your product finds by the customer's external ID. Omit the flag for an environment of your own, such as `staging`.
To skip initial setup, let Alien create a separate AWS account, Google Cloud project, or Azure resource group for each customer: see [Subaccounts](/docs/guide/subaccounts).
The same stack can also back your shared, multi-tenant deployments, one per region. Alien treats each one as a deployment that follows a [release channel](/docs/guide/releases).
## Infrastructure changes without rerunning initial setup [#infrastructure-changes-without-rerunning-initial-setup]
A [frozen resource](/docs/guide/frozen-and-live) is one only initial setup may create or change, such as the bucket or database that holds data. In a customer's account, a release that adds a frozen resource or a permission waits for their admin to rerun initial setup.
In your own account, you can grant ongoing setup authority instead, so those releases apply without a rerun:
```bash
alien deploy --name customer-1-eu --platform aws --automatic-setup
```
Automatic setup on AWS, Google Cloud, and Azure refuses a stack that needs something only a setup package can create, with `AUTOMATIC_SETUP_RESOURCE_UNSUPPORTED`. Use manual setup for a stack with any of these:
* A `Key`, an Email resource, a Kubernetes cluster, or an `experimental/aws-opensearch` resource.
* On AWS, Storage with CORS origins, or a `management: { extend }` grant scoped to one resource rather than `"*"`.
* A `management: { override }` profile.
* On Azure, a Sandbox.
The dashboard uses automatic setup for **Deploy to your environment** on AWS, Google Cloud, and Azure when the stack supports it:
* With the CLI method, it says: **Allow future releases to create, change, and remove infrastructure; new cloud permissions may still need your approval.**
* With CloudFormation, the launch step says **This installs the access Alien uses to create and update this deployment's cloud resources.**
Deployments created without automatic setup keep manual setup: rerun `alien deploy` with the same credentials when a release shows **Setup update needed**.
## Customer-managed key on AWS [#customer-managed-key-on-aws]
A single-tenant deployment in your AWS account can encrypt its data with a key the customer controls. The key can live in the customer's AWS KMS, Google Cloud KMS, or Azure Key Vault. It covers Aurora, S3, and persistent EBS volumes.
A stack with a `Key` deploys only through CloudFormation or Terraform with manual setup: `alien deploy` refuses it with `SETUP_METHOD_CANNOT_INSTALL_RESOURCES`.
1. Declare a `Key` and pass it to each store in `alien.ts`. See [Customer-managed keys in a deployment](/docs/guide/customer-managed-keys/in-a-deployment).
2. The customer connects their key through a setup link that includes **Encryption key** (setup item `keys`). See [Customer-managed keys](/docs/guide/customer-managed-keys).
3. When you deploy the environment in the same deployment group, choose the customer's key under **Encryption key** instead of **Create a key in this AWS account**.
The key choice can't change after the deployment is created.
If the customer revokes the key, the deployment shows **Customer key unavailable** and Alien stops changing the resources that use it. It never replaces them.
When the key is restored, the same database and volumes recover. Uninstalling schedules the AWS key for deletion after 7 days.
This works on AWS only. Root volumes and sandbox images use your account's own keys.
## Next [#next]
* [Subaccounts](/docs/guide/subaccounts): let Alien create an account per customer and deploy into it
* [AWS](/docs/infrastructure/platforms/aws): move the same deployment into the customer's account
* [Customer-managed keys in a deployment](/docs/guide/customer-managed-keys/in-a-deployment): declare the key in `alien.ts`
* [Deployments](/docs/production/deployments): manage many deployments
# Postgres API reference (/docs/infrastructure/postgres/api)
The Postgres API is how your code finds and authenticates to a linked [Postgres](/docs/infrastructure/postgres) database. Its **binding** is the connection details Alien injects into each workload [linked](/docs/infrastructure/using-resources#link) to the resource: where the database is and how to authenticate.
Your code connects with its own driver or ORM: the binding wraps no queries, because every backend speaks the same PostgreSQL wire protocol.
A handle comes from these calls, where `name` is the resource ID from `alien.ts`:
* TypeScript: `postgres(name)` from `@alienplatform/sdk`
* Python: `postgres(name)` from `alienplatform`
* Rust: `Bindings::from_env()?.postgres(name).await?` from `alien-sdk`
## connection [#connection]
Returns the connection details.
```ts
import { postgres } from "@alienplatform/sdk"
import pg from "pg"
const conn = await postgres("db").connection()
const client = new pg.Client({
host: conn.host,
port: conn.port,
database: conn.database,
user: conn.username,
password: conn.password,
ssl: conn.ssl,
})
await client.connect()
```
```python
from alienplatform import postgres
conn = await postgres("db").connection()
# SQLAlchemy with asyncpg
engine = create_async_engine(**conn.sqlalchemy_async_engine_kwargs())
# Or any driver that takes an ssl.SSLContext
ctx = conn.ssl_context() # None when sslmode is "disable"
```
```rust
let db = bindings.postgres("db").await?;
let params = db.connection_params();
let url = db.connection_string(); // postgres://user:password@host:port/database?sslmode=
let roots = params.ca_certificates(); // PEM roots, empty for system trust or no TLS
```
On AWS, GCP, and Azure, resolving the binding reads the password from the cloud's secret store with the workload's identity. The resolved value is reused.
To pick up a rotated password, create a new handle with `postgres(name)` and call `connection()` again.
### PostgresConnection [#postgresconnection]
```ts
type PostgresConnection = {
connectionString: string // postgres://user:password@host:port/database?sslmode=
host: string // the writer endpoint on Aurora
port: number
database: string
username: string
password: string
sslmode: "disable" | "verify-ca" | "verify-full"
ssl: false | PostgresTlsOptions
}
```
* `connectionString` percent-encodes the username, password, and database, so a generated password with URL-special characters can't break it.
* `ssl` is `false` when `sslmode` is `disable`. Otherwise it's a TLS options object for node-postgres:
* `ca` with the provider's PEM certificates
* `rejectUnauthorized: true`
* for `verify-ca` only, a `checkServerIdentity` that skips hostname matching
Python's `PostgresConnection` has `connection_string`, `host`, `port`, `database`, `username`, `password`, `sslmode`, and `ca_certificates`, plus helpers for TLS and SQLAlchemy:
| Method | Returns |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `ssl_context()` | An `ssl.SSLContext` that trusts `ca_certificates` and checks the hostname only for `verify-full`. `None` for `disable`. |
| `sqlalchemy_async_url()` | A `postgresql+asyncpg://` URL. |
| `sqlalchemy_async_engine_kwargs()` | `url` and `connect_args` for `create_async_engine`. |
The Python `repr` hides the password.
Rust returns `PostgresConnectionParams` with `host`, `port`, `database`, `username`, `password`, and `tls`, plus `sslmode()`, `ca_certificates()`, and `connection_string()`. Its `Debug` output hides the password.
## TLS modes [#tls-modes]
| Mode | Used by | What's checked |
| ------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `verify-full` | Aurora, Flexible Server, external databases (default) | The certificate chains to a trusted CA and matches the hostname. |
| `verify-ca` | Cloud SQL | The certificate chains to the instance's CA. The hostname isn't checked, because clients dial the Private Service Connect IP, which isn't in Cloud SQL's certificate. |
| `disable` | Local, or an external database configured with `sslMode: "disable"` | No TLS. |
Managed backends supply the provider's CA certificates. An external database with `verify-full` and no certificates uses the system trust store.
## The binding [#the-binding]
A linked Postgres resource injects `ALIEN__BINDING`, uppercased with hyphens as underscores: `db` becomes `ALIEN_DB_BINDING`. The SDKs read it for you. It's JSON tagged by `service`:
| `service` | Platform | Fields |
| ----------------- | ------------------------------- | ------------------------------------------------------------------------------------------ |
| `aurora` | AWS | `clusterEndpoint`, `port`, `database`, `username`, `passwordSecretArn` |
| `cloud-sql` | GCP | `host`, `port`, `database`, `username`, `serverCaCertificates`, `passwordSecretName` |
| `flexible-server` | Azure | `host`, `port`, `database`, `username`, `passwordSecretUri` |
| `external` | Kubernetes or your own database | `host`, `port`, `database`, `username`, `password`, `sslMode` (`verify-full` or `disable`) |
| `local-postgres` | Local | `host`, `port`, `database`, `username`, `password` |
The cloud variants never contain the password, only where to read it: a Secrets Manager ARN, a Secret Manager name, or a Key Vault secret URI. The workload's identity authorizes the read through `postgres/data-access`.
External and Local carry the password inline. Keep it out of logs.
On the clouds the database is named `app` and the user is `app_admin`. On Local, the database is named after the resource ID and the user is `postgres`. Read both from the connection instead of hard-coding them.
## Errors [#errors]
| Code | Retryable | When |
| ----------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `POSTGRES_SECRET_RESOLUTION_FAILED` | Follows the cloud error | Reading the password from the secret store failed, for example because the workload lacks `postgres/data-access`. Throttling stays retryable. |
| `POSTGRES_SECRET_VALUE_INVALID` | No | The secret exists but doesn't hold a usable password. |
| `INVALID_POSTGRES_TLS_CONFIG` | No | The binding's TLS settings are inconsistent, such as `verify-ca` without a CA. |
| `UNKNOWN_POSTGRES_SSLMODE` | No | The native addon returned an `sslmode` this SDK version doesn't know. Update the SDK. |
| `BINDING_NOT_CONFIGURED` | No | The workload isn't linked to this resource. |
Errors name the secret's locator, never its value. Errors from the database itself come from your driver.
# Postgres behavior and limits (/docs/infrastructure/postgres/behavior)
A [Postgres](/docs/infrastructure/postgres) database is standard PostgreSQL on a private network, with automated backups on the clouds. Its guarantees and limits hold on every platform; sizing and the changes an existing database accepts differ per cloud.
## Guarantees [#guarantees]
**Standard PostgreSQL.** Every platform runs PostgreSQL over the standard wire protocol, with transactions, the full SQL language, and your own driver or ORM. Alien creates the database and resolves how to connect. It doesn't wrap or restrict SQL.
**Private network only.** On AWS, GCP, and Azure the database has no public IP, and there is no option to expose it. Only workloads in the same deployment can reach port 5432.
Local listens on localhost only. On Kubernetes and Machines the customer provides the database and controls its networking.
**Extensions.** On the clouds and Local, `vector` (pgvector), `pg_trgm`, `uuid-ossp`, and `pgcrypto` are available through `CREATE EXTENSION`.
* Each cloud ships its own pgvector build, so the minor version follows the provider.
* Local installs pgvector 0.8.1, built and hosted by Alien.
* On Kubernetes, extensions depend on the database the customer provides.
**Automated backups on the clouds.** AWS and Azure keep automated backups for 7 days. GCP enables automated backups and point-in-time recovery with Cloud SQL's default retention. Local has no backups.
## Limits [#limits]
| Limit | Value | Notes |
| ---------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Engine version | `15`, `16`, `17` | Default `17`. See [Changing a database](#changing-a-database). |
| Storage | Default `20Gi`, grow-only | Shrinking is rejected. Azure raises anything below 32 GiB to 32 GiB. On Azure, use one of its fixed sizes (32, 64, 128 GiB, and so on); Alien passes the size through unchanged. |
| Largest size | GCP 16 vCPU / 60 GiB, Azure 8 vCPU / 32 GiB, AWS 256 ACU | On GCP and Azure, larger requests fail instead of being capped. On AWS, the maximum is capped at 256 ACU. |
| Backup retention | 7 days on AWS and Azure | Cloud SQL default on GCP. None on Local. |
| Resource ID | Used as the cluster, instance, or server name | Keep it short and lowercase. |
## Sizing on each cloud [#sizing-on-each-cloud]
`cpu` and `memory` are sizing hints. Each cloud applies them differently.
### AWS [#aws]
Aurora Serverless v2 bills and scales in Aurora Capacity Units (ACUs).
* **Minimum:** 0 ACU, so the cluster pauses when idle.
* **Maximum:** derived from `memory` at 1 ACU per 2 GiB, rounded up to the next 0.5 ACU and kept between 1 and 256 ACU. Anything up to `2Gi` gives 1 ACU.
* **Without `memory`:** the maximum is 2 ACU.
* `cpu` isn't used.
### GCP [#gcp]
Cloud SQL Enterprise edition. Alien picks the smallest custom tier that fits both `cpu` and `memory`:
| Tier | vCPU | Memory |
| ---------------------------- | ---- | -------- |
| `db-custom-1-3840` (default) | 1 | 3.75 GiB |
| `db-custom-2-7680` | 2 | 7.5 GiB |
| `db-custom-4-15360` | 4 | 15 GiB |
| `db-custom-8-30720` | 8 | 30 GiB |
| `db-custom-16-61440` | 16 | 60 GiB |
### Azure [#azure]
Flexible Server. Alien picks the smallest SKU that fits both `cpu` and `memory`:
| SKU | Tier | vCPU | Memory |
| ------------------------- | --------------- | ---- | ------ |
| `Standard_B1ms` (default) | Burstable | 1 | 2 GiB |
| `Standard_B2s` | Burstable | 2 | 4 GiB |
| `Standard_D4ds_v5` | General Purpose | 4 | 16 GiB |
| `Standard_D8ds_v5` | General Purpose | 8 | 32 GiB |
Azure doesn't offer high availability on the Burstable tier. If you set `.highAvailability()` on Azure, also set `cpu` or `memory` high enough to land on a General Purpose SKU (4 vCPU or more than 4 GiB).
## High availability [#high-availability]
| Platform | What `.highAvailability()` does |
| -------- | ------------------------------------------------------------------------- |
| AWS | Nothing. The Aurora cluster has one writer instance either way. |
| GCP | Regional availability: a standby in another zone with automatic failover. |
| Azure | Zone-redundant high availability: a standby server in another zone. |
## Changing a database [#changing-a-database]
| Change | What happens |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cpu` or `memory` | Resized in place. AWS changes the ACU ceiling, GCP changes the Cloud SQL tier, and Azure changes the SKU. Data is kept. GCP and Azure restart the server briefly. |
| `storage` up | Grown in place. |
| `storage` down | Rejected. |
| `version` down | Rejected. |
| `version` up | Rejected by the controller on AWS, GCP, and Local. A major upgrade is hard to reverse, and Alien doesn't take a backup first. To move versions, create a new Postgres resource and migrate the data. |
| `encryptionKey` | Can't change after the database exists. |
On Local, `cpu` and `memory` have no effect.
## Lifecycle [#lifecycle]
**Frozen or live.** When frozen, the setup template (CloudFormation or Terraform) creates the database during initial setup, and Alien imports and monitors it. When live, Alien creates, updates, and deletes it.
**Deletion removes the data.** Deleting the resource deletes the database with no final snapshot, on every cloud and in every setup template. Export the data first if you need it.
**Passwords.** On the clouds, Alien generates the administrator password and stores it in the cloud's secret store: Secrets Manager, Secret Manager, or Key Vault. The binding never contains the password, only where to read it.
**Customer-managed keys.** On AWS, `.encryptionKey(key)` encrypts storage, snapshots, and Performance Insights data with the customer's [Key](/docs/infrastructure/key).
If the customer revokes the key, Aurora stops serving the database. Alien marks the key unavailable and doesn't replace the database. When access comes back, `postgres/key-recovery` lets Alien restart the cluster.
## Platform notes [#platform-notes]
### AWS [#aws-1]
Aurora Serverless v2 pauses when idle, and you pay for storage only while paused. The first connection after a pause waits about 15 seconds while it resumes, longer after long idle periods.
Use a connect timeout of at least 30 seconds, retry connects, and keep health checks off the database so it can pause.
The cluster sits in a DB subnet group in the deployment's network, with its own security group that allows port 5432 only from the stack.
### GCP [#gcp-1]
Cloud SQL instances are always on: there's no scale-to-zero. Workloads reach the instance through a Private Service Connect endpoint in the deployment's network.
Clients dial the endpoint's IP, which isn't in Cloud SQL's certificate, so the binding uses `verify-ca` instead of `verify-full`.
### Azure [#azure-1]
Flexible Server instances are always on. Workloads reach the server through a private endpoint in the deployment's network, resolved through the `privatelink.postgres.database.azure.com` private DNS zone. Alien allow-lists the four extensions above with the `azure.extensions` server parameter.
### Kubernetes and Machines [#kubernetes-and-machines]
Alien doesn't create Postgres on Kubernetes or Machines. The customer provides a database, and Alien passes it to your workloads as an [external binding](/docs/infrastructure/platforms/kubernetes#connect-the-customers-infrastructure) with `service: "external"`.
On Machines the resource must be `"frozen"`. Networking, extensions, and backups depend on that database.
### Local [#local]
A native PostgreSQL process from embedded binaries, no Docker needed.
* The data directory persists across restarts.
* Alien restarts the process if it exits, and restarts recorded databases when the CLI starts.
# Postgres (/docs/infrastructure/postgres)
Postgres is a managed PostgreSQL database next to your code, with transactions, joins, and `pgvector` for embeddings. It has no public endpoint: only workloads in the same deployment can reach it, so the data stays in that environment.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const db = new alien.Postgres("db")
.version("17")
.memory("4Gi")
.storage("50Gi")
.build()
const api = new alien.Container("api")
.code({ type: "image", image: "ghcr.io/acme/api:1.4.0" })
.cpu(1)
.memory("1Gi")
.port(8080)
.link(db)
.permissions("execution")
.build()
export default new alien.Stack("acme")
.add(db, "frozen")
.add(api, "live")
.permissions({ profiles: { execution: { db: ["postgres/data-access"] } } })
.build()
```
In this stack:
* `.link(db)` connects the database to the `api` Container, the **workload** (the running code) that uses it. Alien injects the database's connection details, its **binding**, into the Container, which your driver uses to connect.
* The `execution` [permission profile](/docs/guide/permissions), the named set of cloud permissions a workload runs with, grants `postgres/data-access`.
* `"frozen"` means the customer's admin creates the database during initial setup. `"live"` resources, like the Container, are created and updated by Alien on every release. See [Frozen and live](/docs/guide/frozen-and-live).
The `api` Container gets connection details from the binding and connects with its usual driver:
```ts title="src/db.ts"
import { postgres } from "@alienplatform/sdk"
import pg from "pg"
const conn = await postgres("db").connection()
const pool = new pg.Pool({
host: conn.host,
port: conn.port,
database: conn.database,
user: conn.username,
password: conn.password,
ssl: conn.ssl,
})
```
## Platforms [#platforms]
`db` becomes a managed database on a private network:
| Platform | Backing service | Created by |
| ---------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| AWS | Aurora PostgreSQL Serverless v2, scaling to zero when idle | Alien |
| GCP | Cloud SQL for PostgreSQL, Enterprise edition, reached through Private Service Connect | Alien |
| Azure | Azure Database for PostgreSQL Flexible Server, reached through a private endpoint | Alien |
| Kubernetes | Any PostgreSQL the customer provides | The customer, through an [external binding](/docs/infrastructure/platforms/kubernetes#connect-the-customers-infrastructure) |
| Machines | Any PostgreSQL the customer provides | The customer, through an external binding. The resource must be `"frozen"`. See [Machines](/docs/infrastructure/platforms/machines). |
| Local | Native PostgreSQL process on localhost, no Docker needed | Alien |
On AWS, GCP, and Azure, a Postgres resource adds a [Network](/docs/infrastructure/network) to the stack, because the database lives on a private network.
## Frozen or live [#frozen-or-live]
Postgres can be `"frozen"` or `"live"`:
* **Frozen:** the setup template creates the database during initial setup, and Alien monitors it.
* **Live:** Alien creates, updates, and deletes it.
See [Frozen and live](/docs/guide/frozen-and-live).
## Alternatives [#alternatives]
The binding only resolves connection details. You connect with your usual driver or ORM.
* For files and blobs, use [Storage](/docs/infrastructure/storage).
* For small values looked up by key with a TTL, use [KV](/docs/infrastructure/kv).
## Options [#options]
| Method | Type | Default | Notes |
| ------------------------------ | ---------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.version(v)` | `"15" \| "16" \| "17"` | `"17"` | Major engine version. Can't go down. Changing it on an existing database is rejected; see [Behavior and limits](/docs/infrastructure/postgres/behavior#changing-a-database). |
| `.cpu(v)` | `string` | smallest tier | vCPUs, such as `"2"`. GCP and Azure pick the smallest tier that fits `cpu` and `memory`. Not used on AWS. |
| `.memory(v)` | `string` | smallest tier | A number with a unit (`Ki`, `Mi`, `Gi`, `Ti`, `k`, `M`, `G`, `T`), such as `"8Gi"`. A bare number such as `"8"` is rejected. On AWS it sets the Aurora capacity ceiling (1 ACU per 2 GiB, at least 1 ACU). |
| `.storage(v)` | `string` | `"20Gi"` | Allocated storage. Can only grow. On Azure, use a fixed size: 32, 64, 128 GiB, and so on. |
| `.highAvailability(on = true)` | `boolean` | `false` | A standby in another zone: regional on GCP, zone-redundant on Azure. Not used on AWS, where the cluster has one writer instance. |
| `.encryptionKey(key)` | `Key` | provider-managed key | Encrypts storage, snapshots, and Performance Insights data with a customer-managed [Key](/docs/infrastructure/key). AWS only. Can't change after the database exists. |
| `.enabled(input)` | boolean stack input | always on | Creates the resource only when the input is `true`. |
Sizes beyond the largest tier:
* **GCP:** up to 16 vCPU and 60 GiB. Larger sizes fail instead of being capped.
* **Azure:** up to 8 vCPU and 32 GiB. Larger sizes fail instead of being capped.
* **AWS:** the ceiling is capped at 256 ACU.
## Use it from code [#use-it-from-code]
`postgres(name).connection()` returns everything a driver needs. On AWS, GCP, and Azure it reads the password from the cloud's secret store with the workload's own identity on the first call.
```ts title="src/db.ts"
import { postgres } from "@alienplatform/sdk"
import pg from "pg"
const conn = await postgres("db").connection()
const pool = new pg.Pool({
host: conn.host,
port: conn.port,
database: conn.database,
user: conn.username,
password: conn.password,
ssl: conn.ssl,
connectionTimeoutMillis: 30_000,
})
const { rows } = await pool.query("select now()")
```
Pass the separate fields and `ssl` to node-postgres, not `connectionString`. node-postgres reads TLS settings from the URL and can overwrite the `ssl` object, including the provider's CA certificates.
```python title="db.py"
from alienplatform import postgres
from sqlalchemy.ext.asyncio import create_async_engine
conn = await postgres("db").connection()
engine = create_async_engine(**conn.sqlalchemy_async_engine_kwargs())
```
```rust title="src/db.rs"
use alien_sdk::Bindings;
let bindings = Bindings::from_env()?;
let db = bindings.postgres("db").await?;
let params = db.connection_params(); // host, port, database, username, password, tls
let url = db.connection_string(); // postgres://...?sslmode=...
```
Every connection field is in the [API reference](/docs/infrastructure/postgres/api).
## Common patterns [#common-patterns]
### Vector search [#vector-search]
`pgvector` is available on every platform Alien creates. Enable it in a migration:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id bigserial PRIMARY KEY,
body text,
embedding vector(1536)
);
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
```
`pg_trgm`, `uuid-ossp`, and `pgcrypto` are available too.
### Cold starts on AWS [#cold-starts-on-aws]
Aurora Serverless v2 scales to zero when idle. The first connection afterwards waits about 15 seconds while it resumes.
* Set a connect timeout of at least 30 seconds, and retry connects.
* Don't point health checks at the database, or it never pauses.
### Application roles [#application-roles]
The binding connects as the database administrator. If your software needs narrower roles, create them in a migration.
## Permissions [#permissions]
| Set | Grants |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `postgres/data-access` | Reading the generated password from the secret store, so the workload can connect. |
| `postgres/key-recovery` | Restarting an Aurora cluster after its encryption key becomes available again. |
| `postgres/provision`, `postgres/management`, `postgres/heartbeat` | Used by initial setup and by Alien's management identity. |
Network access is separate from permissions. On the clouds, only workloads in the deployment's network can reach port 5432. See [Permissions](/docs/guide/permissions).
## Next [#next]
* [API reference](/docs/infrastructure/postgres/api)
* [Behavior and limits](/docs/infrastructure/postgres/behavior)
* [Pricing](/docs/infrastructure/postgres/pricing)
* [Network](/docs/infrastructure/network)
# Postgres pricing (/docs/infrastructure/postgres/pricing)
A small Postgres database costs about $20 to $60 a month, paid by the customer on their own cloud bill for compute, storage, and backups. Alien adds no per-database charge. For Alien's own pricing, see [Pricing](/pricing).
Prices are the providers' list prices as of October 2026 in AWS us-east-1, GCP us-central1, and Azure East US. Official pricing is linked under [Sources](#sources).
## What you pay for [#what-you-pay-for]
| Component | What it is |
| ----------------- | ------------------------------------------------------------------------------ |
| Compute | The instance, or ACU-hours on AWS. |
| Storage | Allocated or used GB per month. |
| I/O | Per request on Aurora Standard. Included on Cloud SQL and Flexible Server. |
| Backups | Backup storage beyond what's included. Retention is 7 days on AWS and Azure. |
| High availability | A standby on GCP and Azure, about double compute and storage. Not used on AWS. |
## How each service bills [#how-each-service-bills]
| Provider | Service | Compute | Storage |
| -------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| AWS | Aurora Serverless v2 | $0.12 per ACU-hour, nothing while paused | $0.10 per GB-month used, plus $0.20 per million I/O requests |
| GCP | Cloud SQL Enterprise | $0.0413 per vCPU-hour and $0.007 per GiB-hour of memory, always on | $0.17 per GiB-month SSD, allocated |
| Azure | Flexible Server | `B1ms` $0.017 per hour (about $12.41 a month); General Purpose `D4ds_v5` $0.356 per hour (about $260 a month); always on | $0.115 per GB-month, allocated, in fixed sizes: 32, 64, 128 GiB, and so on |
AWS is the only cloud where an idle database costs storage only. On GCP and Azure the instance bills every hour it exists.
## Example [#example]
The default size with 50 GiB of storage, running all month (730 hours), no high availability:
| Provider | Compute | Storage | Total |
| ---------------------------------- | ---------------------------------------- | -------------------------------------------- | ---------------------- |
| AWS, busy all month at 0.5 ACU | 0.5 × 730 × $0.12 = $43.80 | 50 × $0.10 = $5.00, plus I/O | **about $49 plus I/O** |
| AWS, active 4 hours a day at 1 ACU | 1 × 122 × $0.12 = $14.64 | $5.00, plus I/O | **about $20 plus I/O** |
| GCP, `db-custom-1-3840` | 730 × ($0.0413 + 3.75 × $0.007) ≈ $49.31 | 50 × $0.17 = $8.50 | **about $58** |
| Azure, `Standard_B1ms` | 730 × $0.017 ≈ $12.41 | 64 × $0.115 ≈ $7.36 (the next fixed size up) | **about $20** |
With `.highAvailability()`, the GCP and Azure numbers roughly double. On Azure, high availability also needs a General Purpose SKU, which raises the base cost.
## Free tiers [#free-tiers]
* **AWS:** accounts on the AWS Free Tier free plan (new accounts since July 15, 2025, for up to 6 months) can run Aurora PostgreSQL serverless clusters with up to 4 ACUs and 1 GiB of storage at no cost. Paid-plan accounts pay the rates above.
* **GCP:** Cloud SQL has no always-free tier.
* **Azure:** new accounts get 12 months of a `B1ms` instance (750 hours a month) with 32 GiB of storage.
## Local [#local]
No cloud charges. Postgres runs as a process on your machine.
## Sources [#sources]
* [Amazon Aurora pricing](https://aws.amazon.com/rds/aurora/pricing/)
* [Cloud SQL pricing](https://cloud.google.com/sql/pricing)
* [Azure Database for PostgreSQL Flexible Server pricing](https://azure.microsoft.com/en-us/pricing/details/postgresql/flexible-server/)
# Queue API reference (/docs/infrastructure/queue/api)
The Queue API is the set of methods your code calls on a linked [Queue](/docs/infrastructure/queue), in the TypeScript, Python, and Rust SDKs. A **binding** is the connection details Alien injects into each workload [linked](/docs/infrastructure/using-resources#link) to the resource, which the SDK client reads.
A handle is bound to one queue, where `name` is the resource ID from `alien.ts`:
* TypeScript: `queue(name)` from `@alienplatform/sdk`
* Python: `queue(name)` from `alienplatform`
* Rust: `Bindings::from_env()?.queue(name).await?` from `alien-sdk`
Creating the TypeScript or Python handle does no I/O. The binding resolves on the first call and is cached. If the workload isn't linked to the resource, the first call throws `BINDING_NOT_CONFIGURED`.
## Methods at a glance [#methods-at-a-glance]
| TypeScript | Python | Rust | What it does |
| --------------------------------------------- | ----------------------------------------------- | -------------------------------- | ----------------------------------------------- |
| [`send(message)`](#send) | `send(message)` | `send(MessagePayload::Json(..))` | Send one JSON message. |
| [`sendText(text)`](#send) | `send_text(text)` | `send(MessagePayload::Text(..))` | Send one text message. |
| [`sendBatch(messages)`](#sendbatch) | `send_batch(messages)` | `send_batch(messages)` | Send JSON messages with native batching. |
| [`sendBatchText(messages)`](#sendbatch) | `send_batch_text(messages)` | `send_batch(messages)` | Send text messages with native batching. |
| [`receive(max)`](#receive) | `receive(max_messages=1)` | `receive(max)` | Receive up to 10 messages. |
| [`ack(receipt)`](#ack) | `ack(receipt_handle)` | `ack(receipt)` | Acknowledge one message. |
| [`ackBatch(receipts)`](#ackbatch) | `ack_batch(receipts)` | `ack_batch(receipts)` | Acknowledge several messages, one outcome each. |
| [`nack(receipt)`](#nack) | `nack(receipt_handle)` | `nack(receipt)` | Release a message for immediate redelivery. |
| [`renewLease(receipt, seconds)`](#renewlease) | `renew_lease(receipt_handle, duration_seconds)` | `renew_lease(receipt, seconds)` | Reset a message's remaining lease. |
| [`purge()`](#purge) | `purge()` | `purge()` | Delete every message. AWS and Local only. |
## send [#send]
Sends one message. `send` serializes the value with `JSON.stringify` (Python `json.dumps`); `sendText` sends the string as is.
```ts
await tasks.send({ type: "resize", imageId: "img_123" })
await tasks.sendText("ping")
```
```python
await tasks.send({"type": "resize", "imageId": "img_123"})
await tasks.send_text("ping")
```
```rust
tasks.send(MessagePayload::Json(json!({ "type": "resize" }))).await?;
tasks.send(MessagePayload::Text("ping".into())).await?;
```
The encoded message must be at most 64 KiB. A larger message fails before any request is sent.
## sendBatch [#sendbatch]
Sends many messages with the backend's own batch API and returns one outcome per message, in input order.
```ts
const results = await tasks.sendBatch(images.map(id => ({ type: "resize", imageId: id })))
results.forEach((result, i) => {
if (result.status === "rejected") console.error(images[i], result.code, result.message)
if (result.status === "unknown") {
// The message may or may not have been accepted. Retrying can duplicate it.
}
})
```
**Returns** [`QueueSendResult[]`](#queuesendresult).
* A message that's empty or over 64 KiB is `rejected` with code `QUEUE_MESSAGE_SIZE_INVALID`, without failing the others.
* Alien doesn't retry.
* If the whole call throws, no message was attempted.
Requests are split to fit each backend: SQS 10 messages and 256 KiB per request, Pub/Sub 1,000 messages and 256 KiB.
## receive [#receive]
Receives up to `max` messages. Each one is leased to you for 30 seconds by default. Until the lease ends, no other consumer receives it.
```ts
for (const message of await tasks.receive(10)) {
const body = message.payloadType === "json" ? JSON.parse(message.payload) : message.payload
await handle(body)
await tasks.ack(message.receiptHandle)
}
```
```python
for message in await tasks.receive(10):
body = json.loads(message.payload) if message.payload_type == "json" else message.payload
await handle(body)
await tasks.ack(message.receipt_handle)
```
```rust
for message in tasks.receive(10).await? {
match &message.payload {
MessagePayload::Json(value) => handle_json(value).await?,
MessagePayload::Text(text) => handle_text(text).await?,
}
tasks.ack(&message.receipt_handle).await?;
}
```
| Parameter | Type | Description |
| --------- | -------- | ------------------------------------------------------------------------------ |
| `max` | `number` | 1 to 10. Anything else fails before any request is sent. Python defaults to 1. |
**Returns** [`QueueMessage[]`](#queuemessage), possibly empty.
`receive` can wait before returning: SQS long-polls for up to 20 seconds, and Service Bus waits up to 30 seconds for each message.
## ack [#ack]
Acknowledges a message, deleting it from the queue. Acknowledging the same receipt twice is safe.
```ts
await tasks.ack(message.receiptHandle)
```
A receipt handle is short-lived and tied to one delivery. After the lease expires and the message is delivered again, the old handle may no longer work.
## ackBatch [#ackbatch]
Acknowledges several messages independently and returns one outcome per receipt, in input order. It isn't atomic.
```ts
const results = await tasks.ackBatch(messages.map(m => m.receiptHandle))
for (const result of results) {
if (result.status === "failed") console.warn(result.code, result.message)
}
```
**Returns** [`QueueAckResult[]`](#queueackresult). A `failed` outcome means completion wasn't confirmed. It may still have succeeded before a network failure.
## nack [#nack]
Releases a message's lease so it's available for redelivery right away, instead of after the lease runs out.
```ts
await tasks.nack(message.receiptHandle)
```
## renewLease [#renewlease]
Sets the remaining lease of a received message to `durationSeconds`, from 1 to 600.
```ts
await tasks.renewLease(message.receiptHandle, 120)
```
| Backend | Effect |
| ------------------- | ------------------------------------------------------------------------------------------------------- |
| SQS, Pub/Sub, Local | The remaining lease becomes exactly `durationSeconds`. A value below the current remainder shortens it. |
| Service Bus | Renews the queue's configured lock duration and ignores `durationSeconds`. |
Renewing can't revive a lease that already expired or a message that was delivered again.
## purge [#purge]
Deletes every message, visible or leased.
```ts
await tasks.purge()
```
Supported on SQS and Local. On Pub/Sub and Service Bus it fails with `OPERATION_NOT_SUPPORTED`.
SQS allows one purge per queue every 60 seconds, and deletion can take up to 60 seconds to finish.
## Types [#types]
### QueueMessage [#queuemessage]
```ts
interface QueueMessage {
payloadType: "json" | "text"
payload: string // JSON text when payloadType is "json", raw text otherwise
receiptHandle: string // Opaque, for ack, nack, and renewLease
attempt: number // Delivery count, 1 = first delivery
}
```
`attempt` is real on SQS and Local. Pub/Sub and Service Bus always report 1 through `receive`.
Python `QueueMessage(payload_type, payload, receipt_handle, attempt)`. Rust `QueueMessage { payload: MessagePayload, receipt_handle: String, attempt: u32 }`, where `MessagePayload` is `Json(serde_json::Value)` or `Text(String)`.
### QueueSendResult [#queuesendresult]
```ts
type QueueSendResult =
| { status: "sent" }
| { status: "rejected"; code: string; message: string } // not accepted
| { status: "unknown"; code: string; message: string } // may have been accepted
```
### QueueAckResult [#queueackresult]
```ts
type QueueAckResult =
| { status: "acknowledged" }
| { status: "failed"; code: string; message: string }
```
Python returns both as dictionaries with the same keys.
## Worker handlers [#worker-handlers]
A Worker with a queue trigger receives messages through handlers from `@alienplatform/sdk` instead of calling `receive`:
| Function | Signature | Notes |
| ------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `onQueueMessage` | `(queue, (message: QueueMessageEvent) => Promise)` | One message per call. Returning acknowledges it; throwing leaves it for redelivery. |
| `onQueueBatch` | `(queue, (batch: QueueBatchEvent) => Promise)` | Needs a trigger with `batch`. Return `{ deliveryId, disposition: "success" \| "retry" }` per delivery. Retrying one delivery doesn't affect the others. |
`QueueMessageEvent` has `id`, `source`, `receiptHandle`, `payload` (parsed), `attemptCount`, `timestamp`, and optional `attributes`. Python has `on_queue_message` and `on_queue_batch`. See [Worker](/docs/infrastructure/worker).
## Errors [#errors]
| Code | When |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `BINDING_SETUP_FAILED` | On SQS, Pub/Sub, and Service Bus: a message over 64 KiB, `max` outside 1 to 10, or a failed backend call. The message says which. |
| `INVALID_INPUT` | A lease duration outside 1 to 600, or invalid input on the local queue. |
| `QUEUE_OPERATION_FAILED` | A failed operation on the local queue. |
| `QUEUE_PROVIDER_RESPONSE_INVALID` | The backend returned something Alien couldn't read. |
| `OPERATION_NOT_SUPPORTED` | `purge` on Pub/Sub or Service Bus. |
| `BINDING_NOT_CONFIGURED` | The workload isn't linked to this resource. |
Cloud queue failures are reported as `BINDING_SETUP_FAILED` even after the binding is set up, such as a throttled receive. Decide whether to retry from the message, not only from the error's `retryable` flag.
# Queue behavior and limits (/docs/infrastructure/queue/behavior)
A [Queue](/docs/infrastructure/queue) delivers every message at least once, in any order, on every platform. This page lists its guarantees, limits, and how each cloud's backing service differs.
## Guarantees [#guarantees]
**At-least-once delivery.** Every message is delivered at least once, and can be delivered more than once. Make consumers idempotent.
**No ordering.** Messages can arrive in any order, on every cloud. If order matters, carry a sequence number in the message.
**Leases.** A received message is hidden from other consumers until it's acknowledged or its lease runs out. Then it's delivered again. `nack` ends the lease early; `renewLease` resets it.
**Idempotent acks.** Acknowledging the same message twice is safe.
**Durability.** SQS, Pub/Sub, and Service Bus store messages redundantly until they're acknowledged or their retention ends.
**No exactly-once.** Some backends offer exactly-once delivery in specific configurations, but not in a form that's portable. Alien doesn't enable it.
On Kubernetes, the guarantees are those of the service in the external binding.
## Limits [#limits]
Alien checks these on every platform:
| Limit | Value |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Message size | 64 KiB (65,536 bytes), after JSON encoding |
| Messages per `receive` | 1 to 10 |
| Lease after `receive` | Pub/Sub and Local: 30 seconds. SQS: the queue's visibility timeout, 30 seconds unless a Worker trigger raises it. Service Bus: the queue's lock duration. |
| `renewLease` duration | 1 to 600 seconds |
| Payload types | JSON or UTF-8 text |
| Queue triggers per Worker | 1 |
| Trigger batch | 1 to 100 messages, window 0 to 300 seconds. More than 10 messages needs a nonzero window. |
## Semantics [#semantics]
**Payloads.** `send` stores the JSON text of the value and `sendText` stores the string. `receive` returns `payloadType` so you know which one you got. The payload is always a string: parse JSON yourself.
**Delivery attempts.** `attempt` starts at 1. SQS reports the real receive count and the local queue its own count. Pub/Sub and Service Bus report 1 through `receive`.
**Batch sends.** `sendBatch` uses each backend's batch API and returns one outcome per message. `unknown` means the request failed in a way that leaves acceptance unclear, so retrying can duplicate. Alien never retries for you.
**Batch acks.** `ackBatch` acknowledges each receipt independently. It isn't a transaction.
**Lease renewal.** On SQS, Pub/Sub, and Local, `renewLease` sets the remaining lease to the given duration, which can shorten it. Service Bus renews its configured lock duration instead. A lease that already expired can't be renewed.
**Purge.** Supported on SQS and Local. Pub/Sub and Service Bus return `OPERATION_NOT_SUPPORTED`.
## Triggers [#triggers]
| Platform | Wiring |
| ---------- | -------------------------------------------------- |
| AWS | SQS event source mapping on the Lambda function |
| GCP | Pub/Sub push subscription to the Cloud Run service |
| Azure | Service Bus scaler (KEDA) on the Container App |
| Kubernetes | Not supported. Consume with `receive`. |
| Local | Alien's local trigger service |
A handler that returns acknowledges its message. A handler that throws leaves it for redelivery. With `batch`, the handler returns `success` or `retry` for each delivery.
## Per-cloud details [#per-cloud-details]
The lease settings below are set to fit the Workers that a queue triggers, so a message isn't redelivered while a Worker is still working on it.
### AWS (SQS) [#aws-sqs]
* Standard queue, not FIFO.
* Visibility timeout: `max(30 seconds, min(12 hours, Worker timeout × 6 + batch window))` for the longest Worker the queue triggers, or 30 seconds when no Worker triggers it. Alien never lowers a timeout that's already higher.
* Queues created at initial setup keep messages for 4 days and use SQS-managed server-side encryption.
* `receive` long-polls for up to 20 seconds.
* SQS accepts messages up to 256 KiB; Alien enforces 64 KiB.
### GCP (Pub/Sub) [#gcp-pubsub]
* A topic with one pull subscription. Ordering is off.
* `receive` sets each received message's ack deadline to 30 seconds.
* In subscriptions declared in the stack, the ack deadline is `Worker timeout × 2`, clamped to 10–600 seconds, or 60 seconds when no Worker triggers the queue. Messages are kept for 7 days and the subscription never expires.
* Pub/Sub accepts messages up to 10 MB; Alien enforces 64 KiB.
### Azure (Service Bus) [#azure-service-bus]
* One Service Bus namespace per stack, Standard tier. Each Queue resource is a queue in it.
* `receive` takes messages one at a time with peek-lock, waiting up to 30 seconds for each.
* In queues created at initial setup, the lock duration is `Worker timeout × 2`, clamped to 5–300 seconds (30 seconds when no Worker triggers the queue).
* Queues created at initial setup keep messages for 14 days. After 10 failed deliveries a message moves to the dead-letter queue. Expired messages are dead-lettered too.
* Queues that Alien creates at runtime use the Service Bus defaults for these settings.
* `nack` abandons the message. `renewLease` renews the lock for the configured lock duration.
* Service Bus Standard accepts messages up to 256 KB; Alien enforces 64 KiB.
### Kubernetes [#kubernetes]
Alien creates nothing. Point each Queue resource at SQS, Pub/Sub, or Service Bus in the Helm values. Worker queue triggers aren't available.
### Local [#local]
* An embedded database file, `localqueue.sqlite`, owned by the local Alien process. Messages survive restarts.
* Applications reach it through Alien's local process, not by opening the file.
* A message is delivered to exactly one receiver per lease. Expired leases are reclaimed on the next `receive`.
* `attempt` counts real deliveries.
## Design decisions [#design-decisions]
**64 KiB messages.** SQS and Service Bus Standard allow 256 KB and Pub/Sub 10 MB. One portable limit means a message that works locally works everywhere. Put large payloads in [Storage](/docs/infrastructure/storage) and send the key.
**10 messages per receive.** SQS returns at most 10 per call, so that's the portable batch size.
**Leases sized from Workers.** Each backend's visibility setting is derived from the Workers a queue triggers, instead of a separate option you'd have to keep in sync.
**Outcomes per message.** Batch sends and acks report each message separately, including the uncertain case, so callers decide what to retry instead of the SDK guessing.
# Queue (/docs/infrastructure/queue)
A Queue is a message queue that hands work from one part of your code to another in the customer's cloud: background jobs, event pipelines, and work that must survive a crash. Messages are delivered at least once on SQS, Pub/Sub, Service Bus, or a local queue, through one API.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const tasks = new alien.Queue("tasks").build()
const api = new alien.Worker("api")
.code({ type: "source", src: "./api", toolchain: { type: "typescript" } })
.link(tasks)
.permissions("producer")
.build()
const processor = new alien.Worker("processor")
.code({ type: "source", src: "./processor", toolchain: { type: "typescript" } })
.trigger({ type: "queue", queue: tasks.ref() })
.build()
export default new alien.Stack("acme")
.add(tasks, "frozen")
.add(api, "live")
.add(processor, "live")
.permissions({
profiles: { producer: { tasks: ["queue/publish"] } },
})
.build()
```
In this stack:
* `.link(tasks)` connects the queue to the `api` Worker, the **workload** (the running code) that sends to it. Alien injects the queue's connection details, its **binding**, into the Worker, read by the SDK client.
* The `producer` [permission profile](/docs/guide/permissions), the named set of cloud permissions a workload runs with, grants `queue/publish`.
* The `processor` Worker gets messages through its trigger instead of a link.
* `"frozen"` means the customer's admin creates the queue during initial setup. `"live"` resources, like the Workers, are created and updated by Alien on every release. See [Frozen and live](/docs/guide/frozen-and-live).
The `api` Worker sends messages, and the `processor` Worker handles them:
```ts title="api/src/index.ts"
import { queue } from "@alienplatform/sdk"
await queue("tasks").send({ imageId: "img_123" })
```
```ts title="processor/src/index.ts"
import { onQueueMessage } from "@alienplatform/sdk"
onQueueMessage<{ imageId: string }>("tasks", async message => {
await resize(message.payload.imageId)
})
```
## Platforms [#platforms]
`tasks` becomes a queue in the customer's cloud:
| Platform | Backing service | Created by |
| ---------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| AWS | SQS standard queue | Alien |
| GCP | Pub/Sub topic and pull subscription | Alien |
| Azure | Service Bus queue in a Standard namespace that Alien adds to the stack | Alien |
| Kubernetes | SQS, Pub/Sub, or Service Bus | The customer, through an [external binding](/docs/infrastructure/platforms/kubernetes#connect-the-customers-infrastructure) |
| Machines | Same services as Kubernetes | The customer, through an external binding. The resource must be `"frozen"`. See [Machines](/docs/infrastructure/platforms/machines). |
| Local | Embedded database file (`localqueue.sqlite`) | Alien |
* **Kubernetes:** Alien doesn't create the queue. The build fails unless each Queue resource has an external binding of type `queue`.
* **Kubernetes triggers:** Worker queue triggers aren't available. Consume with `receive` instead.
## How delivery works [#how-delivery-works]
1. A producer sends a JSON or text message.
2. A consumer receives it, which starts a lease.
3. The consumer processes the message and acknowledges it.
4. If the lease runs out before the acknowledgment, the message is delivered again.
## Frozen or live [#frozen-or-live]
Queue can be `"frozen"` (created once during initial setup by the customer's admin) or `"live"` (created and updated by Alien on releases). See [Frozen and live](/docs/guide/frozen-and-live).
The resource ID must be lowercase letters, digits, and hyphens.
## Alternatives [#alternatives]
* For request-response calls, call the Worker or send a [command](/docs/guide/commands).
* For ordered event streams, use something else: queues don't guarantee order.
## Options [#options]
Queue has no options besides `.enabled(input)`, which creates the resource only when a boolean [stack input](/docs/guide/inputs-and-secrets) is `true`.
Visibility timeouts, lock durations, and ack deadlines are derived from the Workers the queue triggers. See [Behavior and limits](/docs/infrastructure/queue/behavior#per-cloud-details).
## Use it from code [#use-it-from-code]
Get a handle with `queue(name)`. The handle is bound to its queue, so methods don't take a queue name.
```ts title="src/tasks.ts"
import { queue } from "@alienplatform/sdk"
const tasks = queue("tasks")
await tasks.send({ imageId: "img_123" })
for (const message of await tasks.receive(10)) {
const body = JSON.parse(message.payload) as { imageId: string }
await resize(body.imageId)
await tasks.ack(message.receiptHandle)
}
```
```python title="tasks.py"
import json
from alienplatform import queue
tasks = queue("tasks")
await tasks.send({"imageId": "img_123"})
for message in await tasks.receive(10):
body = json.loads(message.payload)
await resize(body["imageId"])
await tasks.ack(message.receipt_handle)
```
```rust title="src/tasks.rs"
use alien_sdk::{traits::MessagePayload, Bindings};
use serde_json::json;
let bindings = Bindings::from_env()?;
let tasks = bindings.queue("tasks").await?;
tasks.send(MessagePayload::Json(json!({ "imageId": "img_123" }))).await?;
for message in tasks.receive(10).await? {
// process message.payload
tasks.ack(&message.receipt_handle).await?;
}
```
The full method list is in the [API reference](/docs/infrastructure/queue/api).
## Trigger a Worker [#trigger-a-worker]
With a `queue` trigger, the Worker runs for each message, as `processor` does above. You don't call `receive` or `ack`:
* Returning from the handler acknowledges the message.
* Throwing makes it redeliver.
For higher throughput, add `batch: { maxMessages, maxWindowSeconds }` to the trigger and register `onQueueBatch`. It returns a `success` or `retry` outcome per delivery.
A Worker can have one queue trigger. See [Events and triggers](/docs/infrastructure/worker/events-and-triggers).
## Common patterns [#common-patterns]
### Long-running jobs [#long-running-jobs]
The default lease is 30 seconds. For longer work, renew the lease while you work, or another consumer gets the message:
```ts
const [message] = await tasks.receive(1)
const timer = setInterval(() => tasks.renewLease(message.receiptHandle, 120), 60_000)
try {
await longJob(message)
await tasks.ack(message.receiptHandle)
} finally {
clearInterval(timer)
}
```
### Retry limits [#retry-limits]
`attempt` counts deliveries, starting at 1. Use it to stop retrying a message that keeps failing:
```ts
if (message.attempt > 5) {
await deadLetters.put(`failed/${crypto.randomUUID()}.json`, Buffer.from(message.payload))
await tasks.ack(message.receiptHandle)
}
```
SQS and the local queue report real counts. Pub/Sub and Service Bus always report 1 through `receive`.
### Large payloads [#large-payloads]
Messages are limited to 64 KiB. Put larger payloads in [Storage](/docs/infrastructure/storage) and send the key.
### New objects in Storage [#new-objects-in-storage]
A Storage can put an event into a queue for every new object that matches a prefix and suffix. Declare it on the Storage with `.notify(queue, rule)`, and read each message with `storageEventFromMessage`. See [Notify a queue](/docs/infrastructure/storage#notify-a-queue).
### Send from your hosted software [#send-from-your-hosted-software]
Publish the queue as a [remote binding](/docs/guide/remote-bindings) to send messages into a customer's environment from your backend.
Remote queues are send-only: `send`, `sendText`, `sendBatch`, and `sendBatchText`.
## Permissions [#permissions]
| Set | Grants |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `queue/data-read` | Receive, acknowledge, nack, and renew leases. |
| `queue/data-write` | Send, and acknowledge received messages. |
| `queue/publish` | Send only, for producers that never consume. |
| `queue/batch-metadata` | Reading the queue's visibility setting, which a batched Worker trigger depends on. |
| `queue/storage-notifications` | Letting Cloud Storage publish [storage notifications](/docs/infrastructure/storage/behavior#notifications) into a live queue. Used by the management identity on Google Cloud. |
| `queue/provision`, `queue/management`, `queue/heartbeat` | Used by initial setup and by Alien's management identity. |
When a workload's profile doesn't name the queue, `.link()` grants `queue/data-read` and `queue/data-write`, and a queue trigger grants `queue/data-read`. Naming the queue in the profile replaces those defaults. See [Permissions](/docs/guide/permissions).
## Next [#next]
* [API reference](/docs/infrastructure/queue/api)
* [Behavior and limits](/docs/infrastructure/queue/behavior)
* [Pricing](/docs/infrastructure/queue/pricing)
* [Worker](/docs/infrastructure/worker)
# Queue pricing (/docs/infrastructure/queue/pricing)
A Queue costs cents to a few dollars per million messages, paid by the customer on their own cloud bill. Alien adds no per-message charge. For Alien's own pricing, see [Pricing](/pricing).
Prices are the providers' list prices as of October 2026 in AWS us-east-1, GCP us-central1, and Azure East US. Pub/Sub charges the same throughput rate in every region. Official pricing is linked under [Sources](#sources).
## How each service bills [#how-each-service-bills]
| Provider | Service | Billed on | Price |
| -------- | -------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| AWS | SQS standard queue | Requests. Each 64 KiB of a request is one request, so every Alien message is one. | $0.40 per million requests, after 1 million free per month |
| GCP | Pub/Sub | Throughput: bytes published plus bytes delivered, with a 1,000-byte minimum per request | $40 per TiB, after 10 GiB free per month |
| Azure | Service Bus Standard | A base charge per namespace plus operations | About $10 per month per namespace, including 13 million operations; then $0.80 per million |
Alien creates one Standard namespace per Azure stack, so the base charge applies once per deployment, however many queues it has.
## What one message costs [#what-one-message-costs]
Processing a message takes several calls, and each is billed:
* **SQS:** a send, a receive, and a delete (ack) are three requests. `receive(10)` and `sendBatch` cover up to 10 messages per request. An empty long poll is one request.
* **Pub/Sub:** a publish and a delivery each count toward throughput. Small messages are billed at the 1,000-byte minimum per request.
* **Service Bus:** a send, a receive, and a complete are three operations.
Worker triggers poll on your behalf. On AWS, the Lambda event source mapping polls continuously, so an idle queue still makes some requests.
## Example [#example]
10 million messages in a month, each under 1 KB, sent, received, and acknowledged one at a time:
| Provider | Usage | Cost |
| ----------------- | ------------------------------------------------------------------------------------------------------------- | ---------------- |
| AWS SQS | 30 million requests − 1 million free = 29 million | **$11.60** |
| GCP Pub/Sub | 20 GB (about 18.6 GiB) of throughput (publish and delivery at the 1 KB minimum) − 10 GiB free = about 8.6 GiB | **about $0.34** |
| Azure Service Bus | 30 million operations − 13 million included = 17 million, plus the base charge | **about $23.60** |
Batching sends and receives cuts the SQS and Service Bus numbers by up to 10×.
## Sources [#sources]
* [Amazon SQS pricing](https://aws.amazon.com/sqs/pricing/)
* [Pub/Sub pricing](https://cloud.google.com/pubsub/pricing)
* [Azure Service Bus pricing](https://azure.microsoft.com/en-us/pricing/details/service-bus/)
# Sandbox API reference (/docs/infrastructure/sandbox/api)
The Sandbox API is the set of methods your app calls on a linked [Sandbox](/docs/infrastructure/sandbox) resource to create sandboxes, run commands, and move files, with its types and errors. Your app reaches it through its **binding**: the connection details Alien injects into each workload [linked](/docs/infrastructure/using-resources#link) to the resource, which the SDK client reads.
The SDK client is the same Rust core in every language. TypeScript and Python wrap it, so behavior, errors, and limits match across SDKs.
## Get the binding [#get-the-binding]
```ts
import { sandbox } from "@alienplatform/sdk"
const box = sandbox("agent") // the Sandbox id from alien.ts
```
```python
from alienplatform import sandbox
box = sandbox("agent")
```
Every method is `async`.
```rust
use alien_sdk::Bindings;
let bindings = Bindings::from_env()?;
let agent = bindings.sandbox("agent").await?; // Arc
```
Request and result types are in `alien_sdk::traits`.
The workload must [link](/docs/infrastructure/sandbox) the Sandbox. Creating and terminating needs `sandbox/management`. Running commands and moving files needs `sandbox/execute`.
## Methods [#methods]
Capabilities differ per platform. An unsupported operation raises `OPERATION_NOT_SUPPORTED`. It never silently does nothing.
See [Operations by platform](/docs/infrastructure/sandbox/behavior#operations-by-platform).
### capabilities [#capabilities]
```ts
capabilities(sandboxId?: string): Promise
```
Without an id, returns the operations new sandboxes support. With an id, returns what that existing sandbox supports. Call it with the id after reconnecting across image upgrades, because an existing session's transport can differ from the one new sandboxes use.
The names are: `files`, `reconnect`, `jobs`, `preview`, `pauseResume`, `snapshot`, `domainEgressRules`, `egressDeny`, `enforcedLimits`, `processLimit`, `sandboxLifetime`, `supervisorPidNamespace`, `supervisorIsolation`. No platform reports `snapshot` today.
### create [#create]
```ts
create(options?: CreateSandboxOptions): Promise
```
Creates a sandbox and waits until it can run commands, so the first command never races its start. On AWS the wait is part of the call and can take seconds. A sandbox that never becomes reachable rejects with `SANDBOX_UNREACHABLE`.
A retry after `SANDBOX_UNREACHABLE` can create a second sandbox. Backends try to clean up when readiness fails, but a failed create can still leave a sandbox without returning its id.
### get [#get]
```ts
get(sandboxId: string): Promise
```
Fetches a sandbox, or `null` if it doesn't exist. Requires `reconnect`. A backend that can't answer raises an error instead, so an absent sandbox and an unreachable one never look the same.
On Kubernetes, only the process that created a sandbox can reconnect to it.
### getOrCreate [#getorcreate]
```ts
getOrCreate(options?: CreateSandboxOptions): Promise
getOrCreate(
sandboxId: string,
options?: Omit,
): Promise
```
Fetches a sandbox, creating it if absent, and reports which it did in `created`.
* A terminated sandbox counts as absent. The replacement can have a different id, so store the id you get back.
* An explicit `egress` updates a sandbox that already exists. Omitting it keeps the existing policy.
* `timeoutMs` bounds only a sandbox this call creates. A found sandbox keeps its lifetime.
* No backend has an atomic create-if-absent. Two callers naming one id can both get `created: true`.
### list [#list]
```ts
list(): Promise
```
Lists this Sandbox resource's sandboxes. Supported on Google Cloud and Local, and on Kubernetes for sandboxes the current process claimed.
AWS and Azure raise `OPERATION_NOT_SUPPORTED`, because enumerating there needs an account-wide grant the sandbox role withholds. Use `get` with an id you saved.
### runCommand [#runcommand]
```ts
runCommand(
sandboxId: string,
command: string,
options: RunCommandOptions,
): AsyncIterable
```
Runs `command` with `options.args` and yields `stdout` and `stderr` frames as the command produces them, then exactly one `exit` frame. The iterator is pull-based: nothing is read from the sandbox until you ask for the next frame, so a slow consumer slows the command's output instead of buffering it.
`timeoutMs` is required. It bounds the command, not the call. A timeout stops the command's process group and the sandbox stays usable.
A child that detaches with `setsid` can keep running until the sandbox ends. A timeout is reported only after the command has verifiably stopped.
Output beyond 4 MiB per stream is dropped, and the exit frame reports `truncated: true`.
### startJob, pollJob, cancelJob [#startjob-polljob-canceljob]
```ts
startJob(sandboxId: string, command: string, options: RunCommandOptions): Promise
pollJob(sandboxId: string, jobId: string, sinceSeq?: number): Promise
cancelJob(sandboxId: string, jobId: string): Promise
```
A job is a command that outlives the call that started it. Nothing holds a stream open, and a caller that goes away can reach the job again by its id. All three require `jobs`.
* `startJob` resolves with the job id. If the start goes unanswered, it raises `SANDBOX_OUTCOME_UNKNOWN`. Don't repeat it: the sandbox may have taken the command.
* `pollJob` returns frames after `sinceSeq`. Omit it to read from the first frame, then pass the highest `seq` you've received. Repeating a poll changes nothing, so `SANDBOX_UNREACHABLE` here is safe to retry.
* `cancelJob` stops the command. Repeating it is safe.
A sandbox holds at most 16 jobs. A finished job counts until a poll reads its result.
### readFile, writeFiles [#readfile-writefiles]
```ts
readFile(sandboxId: string, path: string): Promise
writeFiles(sandboxId: string, files: Record): Promise
```
Paths resolve under `/sandbox`. A leading `/` means `/sandbox`. Paths can't escape it.
Parent directories are created as needed. Each file can be at most 32 MiB.
`writeFiles` writes sequentially, so a failed call can leave earlier files written. Requires `files`. See [Files by platform](/docs/infrastructure/sandbox/behavior#files-by-platform) for how each platform resolves paths.
### updateEgress [#updateegress]
```ts
updateEgress(sandboxId: string, egress: EgressPolicy): Promise
```
Replaces the sandbox's runtime egress policy without replacing the sandbox. Works on running sandboxes, and on paused AWS sandboxes, which receive it on resume.
New connections use the new policy when the call returns. Open connections to removed destinations close within about 30 seconds.
Requires a resource declared with `.egress("internet")`. Otherwise it raises `SANDBOX_EGRESS_REQUIRES_INTERNET`. A failed update may already have reached some gateway replicas.
### preview [#preview]
```ts
preview(sandboxId: string, port: number): Promise
```
Returns an endpoint and headers that reach a port inside the sandbox. The port must be in `.previewPorts()`. Requires `preview`.
Send every header with every request, and request a new preview before `expiresInSeconds` runs out.
### pause, resume [#pause-resume]
```ts
pause(sandboxId: string): Promise
resume(sandboxId: string): Promise
```
Requires `pauseResume`. `pause` freezes the sandbox with its state.
A running command freezes with it, along with its own deadline. A caller streaming that command's output is rejected within a bound rather than held for the length of the pause.
`resume` returns once the sandbox can take a command. A sandbox that has ended rejects with a non-retryable error.
On Azure, a command frozen by a pause is ended by terminating the sandbox, so a later `resume` finds nothing. Disk resume on Azure starts a fresh boot.
### terminate [#terminate]
```ts
terminate(sandboxId: string): Promise
```
Deletes the sandbox. Idempotent: terminating an absent sandbox succeeds. A terminate that can't confirm deletion rejects with a retryable error.
AWS reports `SANDBOX_UNREACHABLE` if it can't confirm termination within 30 seconds. On Kubernetes, terminate deletes only sandboxes the current process claimed, and returns success without deleting another process's sandbox.
## Types [#types]
### CreateSandboxOptions [#createsandboxoptions]
Use command-level `env` for portable code. See [Create options by platform](/docs/infrastructure/sandbox/behavior#create-options-by-platform).
### SandboxInstance [#sandboxinstance]
`ResolvedSandbox` is `{ sandbox: SandboxInstance, created: boolean }`.
### RunCommandOptions [#runcommandoptions]
On AWS and Google Cloud, the platform ends a sandbox at its lifetime whatever is running. A command cut off that way reports `SANDBOX_OUTCOME_UNKNOWN`, not a timeout. Leave room in the lifetime for the longest command.
### CommandFrame [#commandframe]
```ts
type CommandFrame =
| { kind: "stdout" | "stderr"; seq: number; data: Buffer }
| { kind: "exit"; exitCode: number; truncated: boolean }
```
`seq` increases across both streams, so you can interleave them in production order. `data` is raw bytes and isn't necessarily UTF-8.
### JobPoll [#jobpoll]
### SandboxPreview [#sandboxpreview]
### EgressPolicy [#egresspolicy]
```ts
type EgressPolicy = "internet" | { allow: string[] }
```
A target is a hostname, a wildcard hostname, an IPv4 address, or an IPv4 CIDR, with an optional `:port`. A policy holds up to 4,096 targets and 256 KiB. See [Allowlists](/docs/infrastructure/sandbox/behavior#allowlists).
## Python [#python]
Python has the same operations in snake_case. Differences from TypeScript:
| Method | Signature |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `capabilities` | `async capabilities(sandbox_id: str \| None = None) -> frozenset[str]` |
| `create` | `async create(*, sandbox_id=None, tenant_key=None, env=None, timeout_ms=None, egress=None) -> SandboxInfo` |
| `get` | `async get(sandbox_id: str) -> SandboxInfo \| None` |
| `get_or_create` | `async get_or_create(*, sandbox_id=None, tenant_key=None, env=None, timeout_ms=None, egress=None) -> tuple[SandboxInfo, bool]` |
| `list` | `async list() -> list[SandboxInfo]` |
| `run_command` | `async run_command(sandbox_id, command, args, *, timeout_ms, cwd=None, env=None) -> CommandStream` |
| `start_job` | `async start_job(sandbox_id, command, args, *, timeout_ms, cwd=None, env=None) -> str` |
| `poll_job` | `async poll_job(sandbox_id, job_id, *, since_seq=None) -> JobResult` |
| `cancel_job` | `async cancel_job(sandbox_id, job_id) -> None` |
| `read_file` | `async read_file(sandbox_id, path) -> bytes` |
| `write_file` | `async write_file(sandbox_id, path, contents: bytes) -> None` |
| `update_egress` | `async update_egress(sandbox_id, egress: EgressPolicy) -> None` |
| `preview` | `async preview(sandbox_id, port) -> SandboxPreview` |
| `pause`, `resume`, `terminate` | `async pause(sandbox_id) -> None` |
* `args` is positional and required on `run_command` and `start_job`. Pass `[]` for none.
* Await `run_command` to get the stream, then iterate it with `async for`. Call `await stream.close()` to stop reading early.
* `write_file` writes one file per call.
* `SandboxInfo` has `sandbox_id`, `state`, and `generation`. `CommandFrame` has `kind`, `seq`, `data`, `exit_code`, and `truncated`. `JobResult` has `running`, `frames`, `exit_code`, `truncated`, `error_code`, and `error_message`.
## Rust [#rust]
`Arc` from `alien_sdk::Bindings::sandbox`:
```rust
fn capabilities(&self) -> SandboxCapabilities;
async fn session_capabilities(&self, sandbox_id: &str) -> Result;
async fn create(&self, request: CreateSandboxRequest) -> Result;
async fn get(&self, sandbox_id: &str) -> Result>;
async fn get_or_create(&self, request: CreateSandboxRequest) -> Result;
async fn list(&self) -> Result>;
async fn update_egress(&self, sandbox_id: &str, egress: EgressPolicy) -> Result<()>;
async fn run_command(&self, sandbox_id: &str, request: RunCommandRequest)
-> Result>>;
async fn start_job(&self, sandbox_id: &str, request: RunCommandRequest) -> Result;
async fn poll_job(&self, sandbox_id: &str, job_id: &str, since_seq: Option) -> Result;
async fn cancel_job(&self, sandbox_id: &str, job_id: &str) -> Result<()>;
async fn read_file(&self, sandbox_id: &str, path: &str) -> Result>;
async fn write_files(&self, sandbox_id: &str, files: BTreeMap>) -> Result<()>;
async fn preview(&self, sandbox_id: &str, port: u16) -> Result;
async fn pause(&self, sandbox_id: &str) -> Result<()>;
async fn resume(&self, sandbox_id: &str) -> Result<()>;
async fn terminate(&self, sandbox_id: &str) -> Result<()>;
```
`capabilities()` returns a struct of booleans rather than a list of names. `RunCommandRequest` takes `timeout: Duration` instead of milliseconds. `CommandOutput` is `Stdout { seq, data }`, `Stderr { seq, data }`, or `Exit { code, truncated }`.
## Errors [#errors]
| Error | Meaning | Action |
| ------------------------------------ | ------------------------------------------------------------------- | ---------------------------------------------------------- |
| `SANDBOX_OUTCOME_UNKNOWN` | The command, job, or create may have happened. | Check its effects before repeating it. |
| `SANDBOX_COMMAND_FAILED` | Command timeout, refused path, or provider failure. | Don't assume the command had no effects. |
| `SANDBOX_UNREACHABLE` | Create readiness failed, or the image is still building. | Retryable, but a retried create can make a second sandbox. |
| `SANDBOX_NOT_AS_DECLARED` | Azure's effective egress differs from the declaration. | Creation fails. Investigate the policy. |
| `OPERATION_NOT_SUPPORTED` | Operation or option not available on this platform. | Check `capabilities()`. |
| `INVALID_INPUT` | Bad request, such as a zero lifetime. | Correct the request. |
| `SANDBOX_EGRESS_REQUIRES_INTERNET` | A runtime policy without `.egress("internet")`. | Change the declaration. |
| `SANDBOX_EGRESS_INVALID` | Malformed target, or one in a refused range. | Correct the target. |
| `SANDBOX_EGRESS_POLICY_TOO_LARGE` | More than 4,096 targets or 256 KiB. | Shorten the policy. |
| `SANDBOX_EGRESS_GATEWAY_UNAVAILABLE` | The gateway can't apply the policy, or the platform has no gateway. | Check gateway health. |
| `SANDBOX_EGRESS_VERSION_IN_FUTURE` | The SDK clock is more than 60 seconds ahead of the gateway. | Fix clock skew. |
| `SANDBOX_EGRESS_POLICY_STORE_FULL` | The gateway has no room for another policy. | Terminate unused sandboxes. |
See [Errors and retries](/docs/infrastructure/sandbox/behavior#errors-and-retries) for per-platform cases.
# Sandbox behavior and limits (/docs/infrastructure/sandbox/behavior)
What a [Sandbox](/docs/infrastructure/sandbox) guarantees, which operations and options each platform supports, and the limits, lifetimes, egress rules, and errors your app has to handle.
## Guarantees [#guarantees]
**Declared limits are enforced or refused.** Limits are ceilings, not scheduling hints. A platform that can't enforce one, such as `maxProcesses` outside Local or `maxLifetimeSeconds` on Azure, refuses the stack at plan time instead of ignoring it.
**Unsupported operations raise.** Calling an operation the platform lacks raises `OPERATION_NOT_SUPPORTED`. Call `capabilities()` to branch before calling.
**Create waits for readiness.** `create` returns only when the sandbox can run a command.
**Timeouts mean the command stopped.** A command timeout is reported only after the command's process group has verifiably stopped. A timeout that leaves untrusted code running is never reported as a timeout.
**Egress enforcement is outside the sandbox.** Gateway policies and provider network rules run outside the guest. Code inside, including root, can't widen them. A gateway failure never falls back to direct internet access.
**No cloud credentials in the guest.** Alien attaches no cloud identity to a sandbox. See [Instance metadata](/docs/infrastructure/sandbox/security#instance-metadata).
**Not guaranteed:** `create` and `getOrCreate` aren't idempotent. A retried create can make a second sandbox, and two concurrent `getOrCreate` calls for one id can both create one.
## Operations by platform [#operations-by-platform]
Call `capabilities()` before using optional operations on new sandboxes. After reconnecting, call `capabilities(sandboxId)` for that sandbox's operations.
Azure entries describe agent images unless marked native. Catalog images and saved native sessions use native transport.
| Operation | AWS | Google Cloud | Azure | Kubernetes | Local |
| ----------------------- | ------------------------------------------- | ------------ | ------------------------------------- | --------------------- | ---------------------------------------- |
| Create, run, terminate | Yes | Yes | Yes | Yes | Yes |
| Read and write files | Yes | Yes | Yes | Yes | Yes |
| Reconnect with `get` | Yes | Yes | Yes | Creating process only | Yes |
| `list` | No | Yes | No | Creating process only | Yes |
| Jobs | Yes | Yes | Agent images only | Yes | No |
| Pause and resume | Yes | No | Yes. Disk resume starts a fresh boot. | No | No |
| Preview ports | Declared ports, credentials last 30 minutes | No | No | No | Declared ports on `127.0.0.1`, no expiry |
| Runtime egress policies | Yes | No | No | Yes | No |
AWS reports `preview` only when ports are declared. Kubernetes doesn't report `sandboxLifetime` at runtime, because a caller can't choose a deadline when claiming a warm pod.
Google Cloud's `resume` can return a fresh container, so Google Cloud doesn't offer pause. No platform reports `snapshot`.
## Create options by platform [#create-options-by-platform]
| Option | AWS | Google Cloud | Azure | Kubernetes | Local |
| ----------- | ----------------- | ----------------- | --------- | ------------------------ | ------------------------ |
| `sandboxId` | Ignored | Display name only | Ignored | Used | Used |
| `env` | Refused | Refused | Validated | Ignored | Ignored |
| `tenantKey` | Refused | Refused | Refused | Ignored | Ignored |
| `timeoutMs` | Shortens lifetime | Shortens lifetime | Refused | Refused, including reuse | Refused, including reuse |
Use command-level `env` for portable code. Azure refuses create-time `PATH`, `IFS`, `SHELLOPTS`, `BASHOPTS`, `LD_*`, and invalid shell variable names, because the timeout wrapper resolves its tools through them. Agent images also reserve `ALIEN_SANDBOX_*`.
## Lifetime [#lifetime]
| Platform | `maxLifetimeSeconds` | Pause behavior |
| ------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| AWS | 1 to 28,800. Sessions are capped at 8 hours even without it. | The in-guest command timeout stops while paused. A waiting command call can still time out. |
| Google Cloud | Any positive value. The provider sets the upper bound. | No state-preserving pause. |
| Azure | Refused | Agent commands use in-guest timeouts. Native commands can trigger sandbox deletion 10 seconds after their timeout. |
| Kubernetes | 1 to 2,147,483,647. Counts from pod start, including time in the warm pool. | No pause. |
| Local | Refused | No pause. |
A create-time `timeoutMs` can't extend the declared lifetime. Without a declared lifetime, AWS still caps sessions at 8 hours and Google Cloud applies its service default. Always terminate sandboxes after saving their results.
`idlePauseSeconds` pauses an idle sandbox where the platform supports pause.
AWS `resume` waits until commands can run and refuses a terminated sandbox. AWS, Google Cloud, and Azure `terminate` wait for deletion. Kubernetes `terminate` deletes only sandboxes claimed by the current process.
## Limits [#limits]
| Limit | AWS | Google Cloud | Azure | Kubernetes | Local |
| --------------------- | -------------------------- | ----------------------------------- | ----------------------------------------------------- | ----------------------- | -------------------------- |
| CPU and memory | [MicroVM size](#aws-sizes) | Enforced by terminating the sandbox | [Sizing rule](#azure-sizes) | Pod resource limits | Docker limits |
| Disk | MicroVM size | Accepted, not applied | At most 20 GiB per core | Ephemeral storage limit | `/sandbox` size limit |
| `maxProcesses` | Refused | Refused | Refused | Refused | Supported |
| Output per stream | 4 MiB | 4 MiB | 4 MiB. Native: no truncation flag. | 4 MiB | Not capped |
| File size | 32 MiB | 32 MiB | 32 MiB. Native: provider limits. | 32 MiB | Not capped |
| Jobs per sandbox | 16 | 16 | 16. Native: unsupported. | 16 | Unsupported |
| Command timeout | Required | Required | Required. Native: at most 24 hours. | Required | Required, at most 24 hours |
| Create readiness wait | 60 s | Up to 660 s across three stages | 120 s for the provider, then up to 60 s for the agent | Warm-pool claim | Container start |
| Limit | Value |
| ----------------------- | --------------------------------------------------- |
| AWS Sandbox resource id | At most 17 characters, or 16 with `.egress("none")` |
| Egress policy size | 4,096 targets and 256 KiB of canonical JSON |
| Gateway clock skew | SDK at most 60 seconds ahead of the gateway |
| Gateway identity token | At most 24 hours |
| Kubernetes warm pool | 2 idle pods per Sandbox resource |
Output beyond the cap is dropped, and the exit frame reports `truncated: true`. A finished job counts toward the 16 until a poll reads its result. Google Cloud runs commands longer than 30 seconds through its jobs API.
Kubernetes idle pods reserve the declared CPU and memory. A create fails while the pool is empty.
### AWS sizes [#aws-sizes]
A Lambda MicroVM bursts to four times its baseline, and AWS has no setting to cap that. Alien therefore picks the largest size whose **peak** memory and disk fit inside your declared ceilings, then checks that the declared CPU covers that size's peak CPU.
| Baseline memory | Peak memory | Peak CPU | Disk |
| --------------- | ----------- | -------- | ------ |
| 512 MiB | 2 GiB | 1 | 8 GiB |
| 1 GiB | 4 GiB | 2 | 8 GiB |
| 2 GiB | 8 GiB | 4 | 8 GiB |
| 4 GiB | 16 GiB | 8 | 16 GiB |
| 8 GiB | 32 GiB | 16 | 32 GiB |
Without `.limits()`, the 2 GiB baseline size is used. The smallest accepted declaration is `cpu: "1"`, `memory: "2Gi"`, `disk: "8Gi"`. A declaration no size fits is refused rather than rounded.
CPU doesn't choose the size. AWS couples CPU to memory, so a low CPU ceiling with a high memory ceiling is refused instead of silently giving you a smaller machine.
### Azure sizes [#azure-sizes]
CPU is a multiple of `250m`, from `250m` to 16 cores. A value such as `333m` is refused even though it's in range.
Memory is at most 2 GiB per declared core, and disk at most 20 GiB per declared core. Limits are enforced inside the sandbox: an over-allocation raises `MemoryError` in the guest while the sandbox keeps running.
## Files by platform [#files-by-platform]
| Platform | Path resolution | Refused path error |
| ----------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| AWS, Google Cloud, Kubernetes | Under `/sandbox`. A leading `/` means `/sandbox`. `.`, `..`, and symlinks are refused. | `SANDBOX_COMMAND_FAILED` |
| Azure agent | Under `/sandbox`. A leading `/` means `/sandbox`. `.`, `..`, and symlinks are refused. | `INVALID_INPUT` for binding checks, `SANDBOX_COMMAND_FAILED` for agent refusals |
| Azure native | A leading `/` is removed and `..` is refused. Azure resolves the rest. | `INVALID_INPUT` before sending files |
| Local | Under `/sandbox`. A leading `/` means `/sandbox`. `..` is refused. Symlinks aren't checked. | `OPERATION_NOT_SUPPORTED` |
## Egress [#egress]
### Modes [#modes]
| Declaration | Behavior |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.egress("none")` | No outbound connections. No gateway. |
| `.egress("internet")` | Outbound traffic through the deployment's egress gateway, with per-sandbox policies, public-destination checks, and a fixed outbound IP on AWS networks Alien creates. |
| Platform | `"internet"` | Requirements |
| -------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS, CloudFormation or Terraform setup | Supported | A deployment compute cluster and private sandbox subnets. For an existing VPC, supply `SandboxSubnetIds`, `SandboxGatewaySubnetIds`, and `SandboxGatewaySubnetCidrs`. |
| AWS, direct deploy | Supported | A new VPC created by the deployment. Default and existing VPCs are refused. |
| Kubernetes | Supported | A sandboxed RuntimeClass and a CNI that passes the NetworkPolicy probe. |
| Azure, Google Cloud, Local | Refused at plan time | No deployment gateway. |
Unsupported platforms report `SANDBOX_EGRESS_GATEWAY_UNAVAILABLE`. They don't fall back to direct internet access.
On Kubernetes, sandboxes reach only gateway pods on TCP port 3128 and need no DNS exception. The cluster's network decides the outbound IP. Alien doesn't allocate a fixed NAT IP there.
### Allowlists [#allowlists]
Pass `egress` to `create` or `getOrCreate`. Without it, `.defaultPolicy()` applies, and without that the policy is `"internet"`.
| Policy | Allows |
| ------------------ | -------------------------------------------------------------- |
| `"internet"` | Public hostnames. Literal IP addresses need an explicit entry. |
| `{ allow: [...] }` | Only the listed targets. |
| `{ allow: [] }` | No new outbound connections. |
A target is one of:
| Target | Example | Matches |
| ---------------------------- | --------------------- | --------------------------------- |
| Hostname | `api.example.com` | That host |
| Wildcard hostname | `*.example.org` | `example.org` and every subdomain |
| IPv4 address | `203.0.113.10` | That address |
| IPv4 CIDR | `198.51.100.0/24` | That range |
| Any of the above with a port | `api.example.com:443` | Only that port (1 to 65535) |
Hostnames are matched against the TLS SNI or the HTTP `Host` header, so other protocols need an IP or CIDR target.
* Private, link-local, and metadata addresses are refused, as are `*` and public suffixes.
* IPv6 destinations aren't supported.
* Targets are lowercased and canonicalized before they're stored.
The gateway can't see what an allowed service forwards elsewhere. Allow only services you trust, and keep wildcards narrow.
### Changing a policy [#changing-a-policy]
```ts title="src/run.ts"
await box.updateEgress(sandboxId, { allow: ["api.example.com"] })
```
This works on running sandboxes and on paused AWS sandboxes. New connections use the new policy when the call returns. Open connections to destinations that are no longer allowed close within about 30 seconds.
Pass `"internet"` to restore the public-hostname policy.
`getOrCreate` with an explicit `egress` also updates an existing sandbox. Omitting it keeps the existing policy, so set the policy each time you hand a sandbox new work.
### Mail ports [#mail-ports]
Ports 25, 465, and 587 are blocked for `"internet"` and for targets without a port. For TLS-first mail, allow the host and port, such as `smtp.example.com:465`. Plaintext SMTP and STARTTLS need an IP or CIDR entry with an explicit port, such as `":587"`.
Sandboxes share an outbound IP. Mail abuse from one sandbox can get that address blocklisted for every other one.
### Outbound IP [#outbound-ip]
On AWS networks created by Alien, gateway traffic leaves through the deployment's NAT with a fixed IP, shown as **Outbound IP** on the deployment page. On an existing network or Kubernetes, the network administrator decides the outbound addresses.
### Gateway [#gateway]
The deployment runs two gateway replicas with 0.25 vCPU and 512 MiB each, even when no sandbox runs.
* Port 3128 carries traffic; port 3129 takes signed policy updates.
* Each release pins the gateway image by digest.
* AWS puts the replicas behind an internal load balancer. Kubernetes uses a cluster Service.
Policies are signed records held in memory, with no database. A policy write replicates to the replicas before it returns, and a sandbox without a policy record has no gateway access.
Policy storage uses half the gateway's memory limit, normally 256 MiB per replica. A full store refuses writes with `SANDBOX_EGRESS_POLICY_STORE_FULL` and never evicts existing policies.
See [Gateway](/docs/infrastructure/sandbox/security#gateway) for identity tokens, signing keys, and failure behavior.
### Older egress values [#older-egress-values]
Object values still work and keep their original cloud behavior. They don't use the gateway, fixed IP, or runtime allowlists.
| Value | AWS | Google Cloud | Azure | Kubernetes | Local |
| ----------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------ | ----------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `{ mode: "deny" }` | Same as `"none"` | Same as `"none"` | Same as `"none"` | Same as `"none"` | Same as `"none"` |
| `{ mode: "allow" }` | Direct cloud internet path. Explicit root `USER` refused. | Direct internet. Private ranges aren't excluded. | Direct internet. Private ranges aren't excluded. | DNS through `kube-dns`, private and special-use destinations excluded | Docker bridge. Container isolation depends on the runtime honoring `enable_icc`. |
| `{ mode: "allowDomains", domains }` | Saved `privilegedSupervisor` declarations only: exact hostnames through an in-guest filter | Refused | Native hostname filter. DNS lookups can still escape. | Refused | Refused |
`{ mode: "allow" }` is required for [remote sandboxes](/docs/infrastructure/sandbox/customer-sandboxes). An `allowDomains` list with no domains is refused, because it denies everything while reading like an allowlist.
To move to the gateway, change the declaration to `.egress("internet")` and rerun initial setup once. The plan lists the gateway and network resources it adds. Then pass allowlists to `create` or `getOrCreate`.
## Image startup [#image-startup]
On AWS, Google Cloud, and Azure agent images, Alien keeps the image's `ENTRYPOINT` and `CMD` and starts it once per sandbox boot, as the image `USER`, before accepting commands.
* Alien doesn't wait for the process to be ready and doesn't restart it after exit. The sandbox keeps running after it exits. Use a supervisor if you need either.
* Its stdout and stderr go to the agent output. Its stdin is `/dev/null`. The SDK has no API for its status or exit code.
* If it can't start, Alien doesn't retry during that boot. On Google Cloud the agent then exits. On Azure, creation fails the readiness check.
* On shutdown, its process group gets `SIGTERM`, then `SIGKILL` 2 seconds later. When the agent is PID 1, it also reaps orphaned processes.
* Resuming the same sandbox doesn't start another copy. A fresh container is a new boot.
* An image without `ENTRYPOINT` or `CMD` has no startup process.
On AWS, an image without `USER` keeps the earlier startup behavior when the sandbox has no egress connector. Kubernetes runs the supplied image, which must start the agent itself. Local replaces the image command with a keepalive shell.
### Environment [#environment]
The startup process gets `HOME` from its UID's passwd entry, or `/` if there is none, and `HOSTNAME` from the container. Image or create-time values can override both.
Alien sets `ALIEN_SANDBOX_EXEC_UID` and `ALIEN_SANDBOX_EXEC_GID` after the image environment, and `ALIEN_SANDBOX_ROOT` when the agent has it. Gateway mode overrides proxy variables, `NO_PROXY`, and CA bundle variables such as `SSL_CERT_FILE`.
A supervisor should pass these to its workers. See [Environment variables](/docs/reference/environment-variables).
### Image user errors [#image-user-errors]
Builds inspect the image's `USER`, except native Azure catalog builds.
| Build failure | Fix |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `SANDBOX_IMAGE_USER_INVALID`: non-root UID with group 0 | Set a non-root group, such as `USER 65534:65534`. `USER 1000` without a passwd entry defaults to group 0, and `USER app:root` also fails. |
| `SANDBOX_IMAGE_USER_INVALID`: user name missing from `/etc/passwd` | Add the account, or use a numeric UID and GID. |
| `BUILD_CONFIG_INVALID`: `USER` differs across architectures | Use the same UID in each architecture's image. |
Local and Kubernetes refuse an explicit root `USER`, including names that resolve to UID 0.
Local builds reuse a cached image only when its OS and architecture match the target. A mismatched immutable image id fails with `IMAGE_BUILD_REJECTED`. Use a matching image or a registry tag.
## Initial setup and updates [#initial-setup-and-updates]
| Platform | Initial setup and image updates |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | Source builds and private base images require `"live"`, because private ECR access is granted after the deployment registers. A frozen Sandbox stays owned by initial setup. |
| Google Cloud | A release can replace a frozen Sandbox's image. |
| Azure | A release can replace a frozen Sandbox's image. Automatic setup refuses Sandbox resources. |
| Kubernetes, Local | The frozen configuration belongs to initial setup. |
Only AWS provisions a Sandbox at runtime, so `"live"` is refused on every other platform. A live Sandbox's setup inputs (egress, network, build role policy, remote grant) can't change during an update.
A Worker, Container, or Daemon can link a live Sandbox. Other resources, including Build, need it frozen. A Build can't link an `"internet"` Sandbox, because gateway administration is private to workloads.
### Saved releases [#saved-releases]
Releases built before the image-user contract keep their behavior until you rebuild them:
| Platform | Saved image behavior |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| AWS | Ordinary bundles may replace the image command. Gateway and legacy supervisor bundles start it as the command UID. S3 bundle declarations stay on this path. |
| Google Cloud | The agent and commands share one non-root UID. There's no separate startup process. |
| Azure native | Startup and exec use the image `USER`, which may be root. |
| Kubernetes | UID 65534 startup is unchanged. |
| Local | A root or absent-`USER` image runs commands as UID 65534. |
Rebuild from the original OCI base to adopt image-user startup. Declarations saved with `privilegedSupervisor` stay readable. Set the image `USER` instead when you rebuild, and use `"internet"` or `"none"` egress, because a rebuilt root image with that setting fails AWS preflight.
When you upgrade:
* Upgrade managers and Operators before publishing releases built with the image-user contract. Older readers reject the `imageUser` field these releases carry.
* Upgrade the app's SDK together with managers before publishing Azure agent images, because older clients can't speak the agent transport.
* Before rolling back a manager, restore a release it can read.
## Errors and retries [#errors-and-retries]
| Situation | Error or result | Action |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| Command or job may have run | `SANDBOX_OUTCOME_UNKNOWN` | Check its effects before repeating it. |
| Command timeout | `SANDBOX_COMMAND_FAILED`, or `SANDBOX_OUTCOME_UNKNOWN` if the stop can't be confirmed | Don't assume there were no effects. |
| Sandbox reached its lifetime mid-command (AWS, Google Cloud) | `SANDBOX_OUTCOME_UNKNOWN` | Size the lifetime for the longest command. |
| AWS or Google Cloud create readiness failure | `SANDBOX_UNREACHABLE` | A retry can create a second sandbox. |
| Azure image still building | `SANDBOX_UNREACHABLE` | Retry after the image is ready. |
| Azure create response lost | `SANDBOX_OUTCOME_UNKNOWN` | Creation may have succeeded. |
| Azure provider readiness timeout | `SANDBOX_COMMAND_FAILED` | Inspect the failure before creating again. |
| Azure agent reports `PATH_NOT_FOUND` or `JOB_NOT_FOUND` | `SANDBOX_COMMAND_FAILED` | Correct the path or job id. The binding doesn't retry. |
| Azure agent readiness timeout | `SANDBOX_UNREACHABLE` | Check the image startup before retrying. |
| Azure effective egress differs | `SANDBOX_NOT_AS_DECLARED` | Creation fails. Investigate the policy. |
| Google Cloud file transport failure | `SANDBOX_COMMAND_FAILED` | Diagnose connectivity. This isn't a path check. |
| AWS agent outbound path check fails | `EGRESS_PATH_UNEXPECTED` | The sandbox's network doesn't match its declaration. |
| Local route or Kubernetes broker failure | `OPERATION_NOT_SUPPORTED` | Check service reachability and the current process's sandbox claim. |
| Unsupported operation or option | `OPERATION_NOT_SUPPORTED` | Check `capabilities()` and options. |
| Invalid request, including a zero lifetime | `INVALID_INPUT` | Correct the request. |
A failed create can leave a sandbox behind without returning its id. AWS can't list sandboxes, and Google Cloud lists them without saying which caller created each one.
In TypeScript, `isSandboxOutcomeUnknown(error)` from `@alienplatform/bindings` matches `SANDBOX_OUTCOME_UNKNOWN` even inside a wrapped error.
Plan-time refusals appear inside `VALIDATION_FAILED` messages, not as an error's `code`. They include `SANDBOX_PLATFORM_UNSUPPORTED` (the platform has no Sandbox backend, such as Machines), `SANDBOX_CAPABILITY_UNSUPPORTED`, `SANDBOX_LIMIT_INVALID`, and `SANDBOX_EGRESS_GATEWAY_UNAVAILABLE`.
## Design decisions [#design-decisions]
**Limits are refused, not clamped.** A stack that declares a limit the platform ignores reads as bounded while the sandbox isn't. Refusing at plan time keeps the declaration honest.
**AWS sizes by peak, not baseline.** Picking the size whose baseline matches your memory ceiling would let the MicroVM burst to four times that ceiling.
**Command timeouts are required.** A default timeout is a hang waiting for a slow day, in a sandbox running code you don't control.
**Capabilities are published.** Each platform lacks something. Publishing the set lets portable code branch before calling instead of discovering a gap through an error.
**Preview ports are declared up front.** Your app reaches its sandbox through the provider, so it can't widen its own ingress at runtime.
**No `list` on AWS and Azure.** Enumerating there needs an account-wide grant that the sandbox role withholds on purpose.
# Remote Sandboxes for a hosted product (/docs/infrastructure/sandbox/customer-sandboxes)
Remote Sandboxes run the code your product executes for a customer, such as an agent's tool calls or a user's script, inside **sandboxes** in the customer's own cloud: isolated environments, such as microVMs, next to their data. Your product stays hosted; only the sandboxes run there.
It's the hosted-product counterpart of [Deployment UX](/docs/deployment-ux): instead of deploying your software, the customer's admin sets up sandboxes in one account.
If you already deploy into the customer's cloud, declare a [`Sandbox`](/docs/infrastructure/sandbox) resource in `alien.ts` instead.
Your backend creates sandboxes and runs commands through the **Sandbox Gateway**, an API Alien hosts at `https://sandbox.alien.dev`. It names the customer by your **external ID**, your own stable ID for them.
The code runs in the customer's account, and the sandbox's egress policy controls its network access. Commands, their output, and file contents pass through the gateway on the way between your backend and the sandbox.
## Enable the gateway [#enable-the-gateway]
This happens once. In the dashboard, open **Remote Sandboxes**, click **Enable**, then create a server API key. From the CLI:
```bash
alien projects capabilities enable remote-sandbox --max-session-lifetime-seconds 3600
alien api-keys create --for remote-sandbox --description production
```
Save the key as `ALIEN_SANDBOX_API_KEY` on your backend.
Creating setup links and reading connection status need your regular Alien API key (`ALIEN_API_KEY`), used by the client from [Set up a client](/docs/deployment-ux/deploy-button#set-up-a-client).
`--max-session-lifetime-seconds` caps one sandbox session, up to 8 hours (`28800`). It defaults to the saved value, or `3600`.
### Sandbox image [#sandbox-image]
Without an image, sandboxes use Alien's default image for each cloud. To use your own, pass one of these, not both:
```bash
alien projects capabilities enable remote-sandbox --image public.ecr.aws/acme/analysis:v1
alien projects capabilities enable remote-sandbox --src ./sandbox
```
* `--image` takes a public image. Its `linux/arm64` variant runs on AWS, and a `linux/amd64` variant also runs on Azure.
* For Google Cloud, Alien adds the sandbox agent to a copy of the `linux/amd64` variant. It skips that copy when the project's default Google Cloud manager is a [private manager](/docs/production/private-managers), or when the image's layers total more than 512 MiB compressed. For those cases, and for a private image, use `--src` with `FROM `.
* `--src` takes a folder with a Dockerfile (`--dockerfile` for another name). The CLI builds it locally with Docker and pushes it to the project's private repository on each cloud.
* The `linux/arm64` build for AWS is required. Azure and Google Cloud get a `linux/amd64` build when the project has a manager on that cloud and the base image has that architecture. See [Images](/docs/infrastructure/sandbox#images) for one Dockerfile that builds for both.
* An unchanged `--src` folder reuses the image already pushed. Pass `--rebuild` to build and push anyway.
The command prints `Skipped : ` for each cloud the image doesn't reach, such as a cloud with no manager, or a Google Cloud copy still building. `--json` output leaves these lines out. Run the command without either flag to keep the saved image and change only the lifetime.
## Add a Connect sandboxes button [#add-a-connect-sandboxes-button]
This runs on your server, in the route your **Connect sandboxes** button calls. Create a [setup link](/docs/deployment-ux/setup-links) with the `sandbox` item and send the customer to it.
The link opens the [deployment portal](/docs/deployment-ux/deployment-portal), where their admin runs initial setup. The request is `POST /v1/deployment-groups/setup-links`:
TypeScript:
```ts title="lib/connect-sandbox.ts"
import { alien, PROJECT } from "./alien"
export async function connectSandboxLink(customer: { id: string; slug: string }) {
const link = await alien.setupLinks.create({
project: PROJECT,
externalId: customer.id,
name: customer.slug,
setupItems: [{ item: "sandbox", required: true }],
})
return link.deploymentLink
}
```
Python:
```python title="connect_sandbox.py"
from alien_client import PROJECT, alien
def connect_sandbox_link(customer_id: str, customer_slug: str) -> str:
r = alien.post(
"/v1/deployment-groups/setup-links",
json={
"project": PROJECT,
"externalId": customer_id,
"name": customer_slug,
"setupItems": [{"item": "sandbox", "required": True}],
},
)
r.raise_for_status()
return r.json()["deploymentLink"]
```
Rust:
```rust title="src/connect_sandbox.rs"
use serde_json::{json, Value};
use crate::alien::{client, API, PROJECT};
pub async fn connect_sandbox_link(
customer_id: &str,
customer_slug: &str,
) -> reqwest::Result {
let link: Value = client()
.post(format!("{API}/v1/deployment-groups/setup-links"))
.json(&json!({
"project": PROJECT,
"externalId": customer_id,
"name": customer_slug,
"setupItems": [{ "item": "sandbox", "required": true }],
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(link["deploymentLink"].as_str().unwrap_or_default().to_owned())
}
```
In the portal, the customer sees **Sandbox**: "Run the code this product executes on your behalf inside your own cloud." Their admin runs initial setup in their account, which creates the sandbox environment: microVMs on AWS, and the cloud's sandbox service on Google Cloud and Azure.
## Create a sandbox [#create-a-sandbox]
This runs on your server, when your product first needs to run code for the customer. Call `POST /v1/sandboxes` on the gateway with the sandbox key and the customer's external ID:
TypeScript:
```ts title="lib/sandbox.ts"
export const SANDBOX_API = "https://sandbox.alien.dev/v1"
export function headers(customerId: string) {
return {
Authorization: `Bearer ${process.env.ALIEN_SANDBOX_API_KEY}`,
"X-Alien-External-ID": customerId,
"Content-Type": "application/json",
}
}
export async function createSandbox(customerId: string) {
const res = await fetch(`${SANDBOX_API}/sandboxes`, {
method: "POST",
headers: headers(customerId),
body: JSON.stringify({}),
})
if (!res.ok) throw new Error(`create failed: ${res.status} ${await res.text()}`)
const sandbox = (await res.json()) as { sandboxId: string; state: string }
return sandbox.sandboxId // persist it before you run anything
}
```
Python:
```python title="sandbox.py"
import os
import httpx
sandbox_api = httpx.Client(
base_url="https://sandbox.alien.dev/v1",
headers={"Authorization": f"Bearer {os.environ['ALIEN_SANDBOX_API_KEY']}"},
timeout=60,
)
def create_sandbox(customer_id: str) -> str:
r = sandbox_api.post("/sandboxes", headers={"X-Alien-External-ID": customer_id}, json={})
if r.is_error:
raise RuntimeError(f"create failed: {r.status_code} {r.text}")
return r.json()["sandboxId"] # persist it before you run anything
```
Rust:
```rust title="src/sandbox.rs"
use serde_json::{json, Value};
pub const SANDBOX_API: &str = "https://sandbox.alien.dev/v1";
pub fn request(
method: reqwest::Method,
path: &str,
customer_id: &str,
) -> anyhow::Result {
Ok(reqwest::Client::new()
.request(method, format!("{SANDBOX_API}{path}"))
.bearer_auth(std::env::var("ALIEN_SANDBOX_API_KEY")?)
.header("X-Alien-External-ID", customer_id))
}
pub async fn create_sandbox(customer_id: &str) -> anyhow::Result {
let res = request(reqwest::Method::POST, "/sandboxes", customer_id)?
.json(&json!({}))
.send()
.await?;
if !res.status().is_success() {
anyhow::bail!("create failed: {} {}", res.status(), res.text().await?);
}
let sandbox: Value = res.json().await?;
// Persist the ID before you run anything
Ok(sandbox["sandboxId"].as_str().unwrap_or_default().to_owned())
}
```
The body accepts `egress`, `env`, and `timeoutMs` (the sandbox's lifetime). Persist the returned `sandboxId`: every later call uses it, and a new process can reconnect with it.
## Run a command [#run-a-command]
This runs on your server, each time your product executes code. `POST /v1/sandboxes/{id}/exec` streams newline-delimited JSON frames. `stdout` and `stderr` frames carry base64 data, and the stream ends with one `exit` or `error` frame:
TypeScript:
```ts title="lib/sandbox-exec.ts"
import { SANDBOX_API, headers } from "./sandbox"
type Frame =
| { type: "stdout" | "stderr"; seq: number; data: string } // data is base64
| { type: "exit"; code: number; truncated: boolean }
| { type: "error"; code: string; message: string; retryable: boolean }
export async function run(customerId: string, sandboxId: string, script: string) {
const res = await fetch(`${SANDBOX_API}/sandboxes/${sandboxId}/exec`, {
method: "POST",
headers: headers(customerId),
body: JSON.stringify({ command: "python3", args: ["-c", script], timeoutMs: 30_000 }),
})
if (!res.ok) throw new Error(`exec failed: ${res.status}`)
let stdout = ""
for (const line of (await res.text()).split("\n")) {
if (!line.trim()) continue // keepalive
const frame = JSON.parse(line) as Frame
if (frame.type === "stdout") stdout += Buffer.from(frame.data, "base64").toString()
if (frame.type === "exit") return { code: frame.code, stdout }
if (frame.type === "error") throw new Error(`${frame.code}: ${frame.message}`)
}
throw new Error("SANDBOX_OUTCOME_UNKNOWN: do not repeat the command")
}
```
Python:
```python title="sandbox_exec.py"
import base64
import json
from sandbox import sandbox_api
def run(customer_id: str, sandbox_id: str, script: str) -> dict:
body = {"command": "python3", "args": ["-c", script], "timeoutMs": 30_000}
headers = {"X-Alien-External-ID": customer_id}
stdout = b""
path = f"/sandboxes/{sandbox_id}/exec"
with sandbox_api.stream("POST", path, headers=headers, json=body) as r:
r.raise_for_status()
for line in r.iter_lines():
if not line.strip():
continue # keepalive
frame = json.loads(line)
if frame["type"] == "stdout":
stdout += base64.b64decode(frame["data"])
elif frame["type"] == "exit":
return {"code": frame["code"], "stdout": stdout.decode()}
elif frame["type"] == "error":
raise RuntimeError(f"{frame['code']}: {frame['message']}")
raise RuntimeError("SANDBOX_OUTCOME_UNKNOWN: do not repeat the command")
```
Rust:
```rust title="src/sandbox_exec.rs"
use base64::{engine::general_purpose::STANDARD, Engine};
use serde_json::{json, Value};
use crate::sandbox::request;
pub async fn run(
customer_id: &str,
sandbox_id: &str,
script: &str,
) -> anyhow::Result<(i64, String)> {
let body = json!({ "command": "python3", "args": ["-c", script], "timeoutMs": 30_000 });
let path = format!("/sandboxes/{sandbox_id}/exec");
let res = request(reqwest::Method::POST, &path, customer_id)?
.json(&body)
.send()
.await?
.error_for_status()?;
let mut stdout = Vec::new();
for line in res.text().await?.lines().filter(|l| !l.trim().is_empty()) {
let frame: Value = serde_json::from_str(line)?;
match frame["type"].as_str() {
Some("stdout") => {
let data = frame["data"].as_str().unwrap_or_default();
stdout.extend(STANDARD.decode(data)?);
}
Some("exit") => {
let code = frame["code"].as_i64().unwrap_or(-1);
return Ok((code, String::from_utf8_lossy(&stdout).into()));
}
Some("error") => anyhow::bail!("{}: {}", frame["code"], frame["message"]),
_ => {}
}
}
anyhow::bail!("SANDBOX_OUTCOME_UNKNOWN: do not repeat the command")
}
```
`timeoutMs` is required on every command. A stream that ends without an `exit` or `error` frame means the outcome is unknown: the command may have run, so don't repeat it automatically. For commands that outlive one request, start a job instead.
## API [#api]
All paths are under `https://sandbox.alien.dev/v1` and take `Authorization: Bearer $ALIEN_SANDBOX_API_KEY` plus `X-Alien-External-ID`.
| Method | Path | Purpose |
| ---------------------- | ----------------------------------------------- | ---------------------------------- |
| `GET` | `/capabilities` | Operations new sandboxes support |
| `POST`, `GET` | `/sandboxes` | Create; list |
| `GET`, `PUT`, `DELETE` | `/sandboxes/{id}` | Get; get or create; terminate |
| `POST` | `/sandboxes/{id}/exec` | Run a command, streamed |
| `POST` | `/sandboxes/{id}/jobs` | Start a long-running command |
| `GET`, `DELETE` | `/sandboxes/{id}/jobs/{jobId}` | Read output (`?sinceSeq=`); cancel |
| `GET` | `/sandboxes/{id}/files?path=` | Read a file |
| `POST`, `PUT` | `/sandboxes/{id}/files` | Write files |
| `POST` | `/sandboxes/{id}/pause`, `/resume`, `/snapshot` | Pause, resume, snapshot |
Limits: 64 KiB JSON request bodies, 32 MiB per file, 64 MiB per write, and 24 hours per job. Listing isn't available on AWS or Azure, so keep the IDs you create. Use `/capabilities` to check what a backend supports before you rely on jobs, files, or pause.
## Show the connection [#show-the-connection]
This runs on your server, when your settings page loads. Read `capabilities.remoteSandbox` from the capability overview, `GET /v1/projects/{idOrName}/project-capabilities/overview`, with the `capability` helper from [Customer-managed keys](/docs/guide/customer-managed-keys/add-to-your-product#show-the-connection):
TypeScript:
```ts title="lib/sandbox-status.ts"
import { capability } from "./capability"
export async function sandboxStatus(customerId: string) {
const sandbox = await capability(customerId, "remoteSandbox")
return { state: sandbox?.state ?? "not-connected", update: sandbox?.sandboxUpdate }
}
```
Python:
```python title="sandbox_status.py"
from capability import capability
def sandbox_status(customer_id: str) -> dict:
sandbox = capability(customer_id, "remoteSandbox") or {}
return {"state": sandbox.get("state", "not-connected"), "update": sandbox.get("sandboxUpdate")}
```
Rust:
```rust title="src/sandbox_status.rs"
use serde_json::{json, Value};
use crate::capability::capability;
pub async fn sandbox_status(customer_id: &str) -> reqwest::Result {
let sandbox = capability(customer_id, "remoteSandbox").await?.unwrap_or(Value::Null);
let state = sandbox["state"].as_str().unwrap_or("not-connected");
Ok(json!({ "state": state, "update": sandbox["sandboxUpdate"] }))
}
```
`state` is `not-connected`, `setting-up`, `connected`, `needs-attention`, or `revoked`. `sandboxUpdate` says whether the customer's sandbox runs your current image: `up-to-date`, `behind`, `blocked`, `update-failed`, `bundle-failed`, or `platform-removed`.
## Test it [#test-it]
Try it as your own test customer before you ship:
```bash
alien onboard "Test customer" --external-id org_test --setup-items sandbox
export CUSTOMER_ID=org_test
alien examples remote-sandbox --operation create
alien examples remote-sandbox --operation exec --command 'python3 --version'
alien logs --source remote-sandbox --since 1h
```
Open the printed link and run initial setup in a cloud account you control. `alien examples` prints a ready-to-run `curl` request for each operation.
Then create a sandbox from your backend, run a command, and confirm the output and exit code reach your product. See [Sandbox](/docs/infrastructure/sandbox) for the `Sandbox` resource you can also declare in your own stack.
## Next [#next]
* [Customer buckets](/docs/infrastructure/storage/customer-buckets): give sandboxes data to work on
* [Brand the portal](/docs/deployment-ux/branding): white-label the portal the customer sees
* [Sandbox](/docs/infrastructure/sandbox): sandboxes inside a deployment
# Sandbox (/docs/infrastructure/sandbox)
A Sandbox is a resource your app uses to create isolated environments at runtime and run code it doesn't trust inside them: an AI agent's tool calls, a customer's script, a build of a customer's repository. The code inside gets no cloud credentials, so it can't reach other resources in the stack.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const agent = new alien.Sandbox("agent")
.code({ type: "image", image: "ubuntu:24.04" })
.limits({ cpu: "1", memory: "2Gi", disk: "8Gi" })
.egress("internet")
.lifecycle({ maxLifetimeSeconds: 3600 })
.build()
const api = new alien.Worker("api")
.code({ type: "source", src: "./api", toolchain: { type: "typescript" } })
.link(agent)
.permissions("execution")
.build()
export default new alien.Stack("acme")
.add(agent, "frozen")
.add(api, "live")
.permissions({
profiles: { execution: { agent: ["sandbox/management", "sandbox/execute"] } },
})
.build()
```
In this stack:
* `.link(agent)` connects the Sandbox resource to the `api` Worker, the **workload** (the running code) that uses it. Alien injects the Sandbox resource's connection details, its **binding**, into the Worker, read by the SDK client.
* The `execution` [permission profile](/docs/guide/permissions), the named set of cloud permissions a workload runs with, grants `sandbox/management` and `sandbox/execute`: creating sandboxes and running commands in them.
* `"frozen"` means the customer's admin creates the Sandbox resource during initial setup. `"live"` resources, like the Worker, are created and updated by Alien on every release. See [Frozen and live](/docs/guide/frozen-and-live).
| Platform | Provisions | Isolation boundary | `"internet"` egress |
| ------------ | -------------------------------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| AWS | Lambda MicroVMs, egress gateway | MicroVM. No execution role attached. | Supported. Needs a VPC created by the deployment, or private sandbox subnets supplied through CloudFormation or Terraform. |
| Google Cloud | Agent Platform sandboxes | Provider container boundary | Not supported |
| Azure | Container Apps sandboxes | Container Apps sandbox | Not supported |
| Kubernetes | Warm pool of Pods with a sandboxed RuntimeClass (gVisor or Kata), egress gateway | Sandboxed runtime, no service account token, NetworkPolicy | Supported. Needs a CNI that enforces NetworkPolicy. |
| Machines | Not supported | | |
| Local | Docker containers | Shares the host kernel. For development only. | Not supported |
A platform that can't deliver a declaration refuses it at plan time. See [Behavior and limits](/docs/infrastructure/sandbox/behavior) for operations, limits, and lifetimes per platform, and [Security](/docs/infrastructure/sandbox/security) for the isolation boundary.
The `api` Worker creates a sandbox, runs a command in it, and deletes it:
```ts title="api/src/run.ts"
import { sandbox } from "@alienplatform/sdk"
const box = sandbox("agent")
const { sandboxId } = await box.create()
const options = { args: ["hello"], timeoutMs: 30_000 }
for await (const frame of box.runCommand(sandboxId, "echo", options)) {
if (frame.kind === "stdout") process.stdout.write(frame.data)
}
await box.terminate(sandboxId)
```
Link the Sandbox to the workload that creates sandboxes, and grant that workload `sandbox/management` and `sandbox/execute`.
## Lifecycle and ids [#lifecycle-and-ids]
The image, limits, and outbound access are fixed in `alien.ts`. Sandboxes themselves come and go at runtime, created and deleted by your app.
A Sandbox resource is `"frozen"` on every platform: the customer's admin creates it during initial setup. On AWS it can also be `"live"`, and it must be `"live"` when it uses a source build or a private base image. See [Frozen and live](/docs/guide/frozen-and-live).
Sandbox ids must be DNS-compatible: lowercase letters, digits, and hyphens, starting and ending with a letter or digit, with no consecutive hyphens. On AWS they can be at most 17 characters, or 16 with `.egress("none")`.
For your own services, use a [Worker](/docs/infrastructure/worker), [Container](/docs/infrastructure/container), or [Daemon](/docs/infrastructure/daemon) instead.
## Use it in code [#use-it-in-code]
```ts title="src/run.ts"
import { sandbox } from "@alienplatform/sdk"
const box = sandbox("agent")
const { sandboxId } = await box.create({ egress: { allow: ["api.example.com"] } })
try {
await box.writeFiles(sandboxId, { "work/input.txt": "hello\n" })
for await (const frame of box.runCommand(sandboxId, "sh", {
args: ["-c", "cat work/input.txt > work/output.txt"],
timeoutMs: 30_000,
})) {
if (frame.kind === "stdout") process.stdout.write(frame.data)
if (frame.kind === "stderr") process.stderr.write(frame.data)
if (frame.kind === "exit") console.log("exit code", frame.exitCode)
}
const output = await box.readFile(sandboxId, "work/output.txt")
console.log(output.toString())
} finally {
await box.terminate(sandboxId)
}
```
```python title="run.py"
from alienplatform import sandbox
box = sandbox("agent")
info = await box.create(egress={"allow": ["api.example.com"]})
try:
await box.write_file(info.sandbox_id, "work/input.txt", b"hello\n")
async for frame in await box.run_command(
info.sandbox_id, "sh", ["-c", "cat work/input.txt > work/output.txt"], timeout_ms=30_000
):
if frame.kind == "exit":
print("exit code", frame.exit_code)
output = await box.read_file(info.sandbox_id, "work/output.txt")
finally:
await box.terminate(info.sandbox_id)
```
```rust title="src/run.rs"
use std::{collections::BTreeMap, time::Duration};
use alien_sdk::traits::{CommandOutput, CreateSandboxRequest, RunCommandRequest};
use alien_sdk::Bindings;
use futures::StreamExt;
let bindings = Bindings::from_env()?;
let agent = bindings.sandbox("agent").await?;
let created = agent.create(CreateSandboxRequest::default()).await?;
let id = created.sandbox_id;
agent
.write_files(&id, BTreeMap::from([("work/input.txt".into(), b"hello\n".to_vec())]))
.await?;
let mut frames = agent
.run_command(&id, RunCommandRequest {
command: "sh".into(),
args: vec!["-c".into(), "cat work/input.txt > work/output.txt".into()],
cwd: None,
env: BTreeMap::new(),
timeout: Duration::from_secs(30),
})
.await?;
while let Some(frame) = frames.next().await {
if let CommandOutput::Exit { code, .. } = frame? {
println!("exit code {code}");
}
}
let output = agent.read_file(&id, "work/output.txt").await?;
agent.terminate(&id).await?;
```
* Keep the returned `sandboxId`. A provider can allocate an id different from the one you asked for. Pass it to `get` to reconnect later.
* Every command needs a timeout. A nonzero exit code is a command result, not an error.
* Commands run a program directly. For shell syntax, pass `sh` with `args: ["-c", line]`.
* `SANDBOX_OUTCOME_UNKNOWN` means the command may have run. Check its effects before you run it again.
* Save results you want to keep in [Storage](/docs/infrastructure/storage) before you terminate the sandbox.
See the [API reference](/docs/infrastructure/sandbox/api) for every method and type.
## Outbound access [#outbound-access]
`.egress("internet")` sends outbound traffic through an egress gateway in the deployment's network:
The gateway allows only public destinations. Private ranges, cloud metadata, the deployment network, and the container network are always blocked. The enforcement runs outside the sandbox, so code inside it can't widen it.
Each sandbox has its own policy. Pass it to `create`, or change it later with `updateEgress`:
| Policy | Allows |
| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `"internet"` | Public hostnames. Literal IP addresses need an explicit entry. |
| `{ allow: ["api.example.com", "*.example.org", "203.0.113.10:443", "198.51.100.0/24"] }` | Only these targets. |
| `{ allow: [] }` | No new outbound connections. |
Without `egress` on `create`, `.defaultPolicy()` applies, and without that the policy is `"internet"`.
Mail ports 25, 465, and 587 need an entry that names the port.
`.egress("none")` blocks outbound connections without a gateway. On AWS the VPC DNS resolver stays reachable from sandboxes, so treat DNS as a possible outbound channel.
See [Egress](/docs/infrastructure/sandbox/behavior#egress) for target syntax, policy changes, outbound IPs, and older egress values.
## Images [#images]
| Platform | Image | Source builds |
| ------------ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| AWS | ARM64 Lambda MicroVM bundle | `linux/arm64`. Source builds and private base images require `"live"`. |
| Google Cloud | Image with the Alien sandbox agent, in Artifact Registry | `linux/amd64`. The release adds the agent. |
| Azure | Registry image with the Alien agent added, or a native catalog image such as `ubuntu` | `linux/amd64`. The release adds the agent. |
| Kubernetes | Image with the Alien sandbox agent | Not supported. Use a prebuilt image. |
| Local | Docker image on your machine | Not supported. Use a prebuilt image. |
To build from a Dockerfile, use a source declaration:
```ts title="alien.ts"
.code({ type: "source", src: "./sandbox", toolchain: { type: "docker" } })
```
Run `alien build`, then `alien release`. A Dockerfile shared across clouds needs a base published for both architectures, or one base per architecture. Docker sets `TARGETARCH` for each build:
```dockerfile title="sandbox/Dockerfile"
FROM public.ecr.aws/lambda/microvms:al2023-minimal AS base-arm64
FROM python:3.14-slim AS base-amd64
FROM base-${TARGETARCH}
```
Add your application and its dependencies after the final `FROM`.
Per-platform image requirements:
* **Google Cloud**: the sandbox service agent needs Artifact Registry Reader on the image repository.
* **Azure and Local**: native Azure images and Local images need `sh`, `setsid`, `od`, `tr`, `sleep`, and `kill` for command timeouts.
* **Azure**: give a changed image a new tag or digest, because Azure builds one disk image per reference.
The image's `USER` decides which identity runs your image's startup command and which runs commands. See [Image startup](/docs/infrastructure/sandbox/behavior#image-startup) and [Users and privileges](/docs/infrastructure/sandbox/security#users-and-privileges).
## Options [#options]
| Method | Type | Default | Notes |
| -------------------------- | -------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `.code(code)` | `SandboxCode` | required | Prebuilt image or Dockerfile source. See [Images](#images). |
| `.egress(mode)` | `"none" \| "internet"` | required | `"none"` blocks outbound connections. `"internet"` routes through the [egress gateway](#outbound-access). |
| `.lifecycle(policy)` | `{ maxLifetimeSeconds?, idlePauseSeconds? }` | required | Pass `{}` to set neither. `maxLifetimeSeconds` is refused on Azure and Local. |
| `.limits(limits)` | `{ cpu, memory, disk, maxProcesses? }` | platform default | Enforced ceilings, not scheduling hints. A platform that can't enforce one refuses the stack at plan time. |
| `.defaultPolicy(policy)` | `"internet" \| { allow: string[] }` | `"internet"` | Policy for sandboxes created without one. Requires `.egress("internet")`. |
| `.previewPorts(ports)` | `number[]` | `[]` | Only these ports can be exposed with `preview`. |
| `.privateBaseImage(image)` | `string` | none | Private ECR base image for an AWS build. AWS only, and the Sandbox must be `"live"`. |
| `.enabled(input)` | `StackInputRef` | always | Gates the resource on a boolean stack input. See [Using resources](/docs/infrastructure/using-resources#common-to-every-resource). |
`Sandbox.any()` returns the `"sandbox"` resource type, for permission targets that apply to every Sandbox.
### Limits [#limits]
`cpu` takes cores or millicores (`"1"`, `"500m"`). `memory` and `disk` take Kubernetes quantities (`"2Gi"`, `"512Mi"`). `maxProcesses` must be at least 1 and works only on Local.
Each platform enforces limits differently:
* **AWS** picks a MicroVM size from your memory and disk ceilings. The smallest accepted declaration is `cpu: "1"`, `memory: "2Gi"`, `disk: "8Gi"`. See [AWS sizes](/docs/infrastructure/sandbox/behavior#aws-sizes).
* **Azure** takes CPU in steps of `250m` from `250m` to 16 cores, with at most 2 GiB of memory and 20 GiB of disk per core.
* **Google Cloud** enforces CPU and memory by terminating a sandbox that exceeds them. It accepts `disk` but doesn't apply it.
* **Kubernetes** sets Pod resource limits and an ephemeral storage limit.
* **Local** sets Docker limits and caps the size of `/sandbox`.
## Common patterns [#common-patterns]
### Reuse a sandbox per task [#reuse-a-sandbox-per-task]
```ts title="src/run.ts"
const { sandbox: reused, created } = await box.getOrCreate("task-42", {
egress: { allow: ["api.example.com"] },
})
await saveSandboxId("task-42", reused.sandboxId)
```
An explicit `egress` also updates a sandbox that already exists, so set the policy each time you hand a sandbox new work.
Save the returned id: replacing a missing or terminated sandbox can return a new one. `getOrCreate` isn't atomic, so two concurrent callers can each create a sandbox. Use your own lock if setup must not run twice.
### Long-running work [#long-running-work]
`runCommand` holds a stream open for the length of the command. For work that outlives a request, start a job and poll it:
```ts title="src/run.ts"
const jobId = await box.startJob(sandboxId, "make", { args: ["test"], timeoutMs: 600_000 })
let seq: number | undefined
for (;;) {
const poll = await box.pollJob(sandboxId, jobId, seq)
for (const frame of poll.frames) {
if (frame.kind !== "exit") seq = frame.seq
}
if (!poll.running) break
await new Promise(resolve => setTimeout(resolve, 1000))
}
```
Jobs need the `jobs` capability. Call `capabilities(sandboxId)` first if your code runs on more than one platform.
### Preview a port [#preview-a-port]
Declare the port with `.previewPorts([8080])`, then:
```ts title="src/run.ts"
const preview = await box.preview(sandboxId, 8080)
await fetch(preview.endpoint, { headers: preview.headers })
```
Preview works on AWS (credentials last 30 minutes) and Local (on `127.0.0.1`, no expiry).
### Run a service inside the sandbox [#run-a-service-inside-the-sandbox]
The image's `ENTRYPOINT` and `CMD` start once per boot, before commands are accepted. Alien doesn't wait for the service to be ready or restart it, so use a supervisor. This image starts a root supervisor that gives a directory to UID 2000 and runs a worker under that UID:
```dockerfile title="sandbox/Dockerfile"
FROM python:3.14-slim
COPY supervisor.py /opt/supervisor.py
USER 0:0
ENTRYPOINT ["python3", "/opt/supervisor.py"]
```
```python title="sandbox/supervisor.py"
import os
import subprocess
import sys
import time
os.makedirs("/tmp/worker", exist_ok=True)
os.chown("/tmp/worker", 2000, 2000)
while True:
subprocess.run(
[sys.executable, "-m", "http.server", "8081", "--directory", "/tmp/worker"],
user=2000, group=2000, extra_groups=[], check=False,
)
time.sleep(1)
```
Build it with a source declaration. On AWS, use `.egress("internet")` or `.egress("none")`, because a root image needs the connector that initial setup provides.
Your app must check the worker's readiness before using it. `runCommand` and `startJob` still run as the separate command identity.
### Remote sandboxes [#remote-sandboxes]
A [remote sandbox](/docs/infrastructure/sandbox/customer-sandboxes) is driven from outside the deployment with cloud credentials. It must use the older `{ mode: "allow" }` egress and declare no preview ports.
`"internet"` can't be combined with `remoteAccess`, because the gateway's policy endpoint is private to the deployment. See [Remote bindings](/docs/guide/remote-bindings).
A remotely published Sandbox must be the deployment's only `remoteAccess` resource, and nothing else in the deployment may reach its sandboxes:
* no link to it,
* no permission profile or service account set that covers its sandboxes,
* no resource-scoped management grant on it.
No cloud can scope a remote grant to some sessions only, so the plan refuses these stacks.
The remote caller holds cloud credentials, so its cloud grant bounds it, not the Sandbox declaration. For example, declared preview ports don't limit it on AWS.
## Permissions [#permissions]
| Set | Grants |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `sandbox/management` | Creating and terminating sandboxes and changing gateway policies. No access to sandbox contents. |
| `sandbox/execute` | Running commands and moving files inside an existing sandbox. On Azure, also lifecycle operations. |
| `sandbox/remote-execute` | Direct cloud access for a [remote](/docs/infrastructure/sandbox/customer-sandboxes) caller to create sandboxes and run code in them. |
| `sandbox/templates` | Creating and replacing the template a sandbox launches from, which sets its image and internet access. Google Cloud. |
| `sandbox/images` | Building, reading, and deleting sandbox disk images. Azure. |
| `sandbox/protect-setup-roles` | Refuses changes to the IAM roles initial setup creates for sandboxes. AWS. |
| `sandbox/provision`, `sandbox/heartbeat` | Used by initial setup and the management identity. |
Google Cloud grants bind at project scope. Use separate projects where deployments need separate sandbox access. See [Permissions](/docs/reference/permissions).
## Next [#next]
* [API reference](/docs/infrastructure/sandbox/api)
* [Behavior and limits](/docs/infrastructure/sandbox/behavior)
* [Security](/docs/infrastructure/sandbox/security)
* [Pricing](/docs/infrastructure/sandbox/pricing)
* [Remote sandboxes](/docs/infrastructure/sandbox/customer-sandboxes)
# Sandbox pricing (/docs/infrastructure/sandbox/pricing)
Sandboxes run in the customer's cloud account, so the customer pays the cloud provider for them. Alien bills separately for deployment management. See [Pricing](/pricing) for Alien's rates.
Prices on this page are AWS list prices as of October 2026 in us-east-1. The customer pays them on their own cloud bill. Official pricing: [AWS Lambda](https://aws.amazon.com/lambda/pricing/), [Google Cloud Agent Platform](https://cloud.google.com/products/gemini-enterprise-agent-platform/pricing), [Azure Container Apps](https://azure.microsoft.com/en-us/pricing/details/container-apps/).
## Provider charges [#provider-charges]
| Platform | What you pay for |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | [Lambda MicroVMs](https://aws.amazon.com/lambda/pricing/): compute per second, snapshot writes and reads on pause and resume, snapshot and image storage, and data transfer. |
| Google Cloud | [Agent Platform](https://cloud.google.com/products/gemini-enterprise-agent-platform/pricing): Agent Compute and Agent Memory. |
| Azure | [Container Apps](https://azure.microsoft.com/en-us/pricing/details/container-apps/): sandbox compute. |
| Kubernetes | Cluster nodes, disks, load balancers, and network traffic. The two idle warm-pool pods per Sandbox reserve their declared CPU and memory. |
| Local | Your machine's resources. |
### AWS rates [#aws-rates]
| Item | Price |
| ---------------------------------- | ----------------------------------------------- |
| Compute, vCPU | $0.0000276944 per vCPU-second |
| Compute, memory | $0.0000036667 per GB-second |
| Snapshot write (on pause) | $0.0038 per GB |
| Snapshot read (on start or resume) | $0.00155 per GB |
| Snapshot and image storage | $0.000111 per GB-hour, about $0.08 per GB-month |
These are Arm (Graviton) rates.
* You pay for the MicroVM's baseline while it runs, and for usage above baseline while it bursts. A MicroVM can burst to four times its baseline.
* Image storage has a 1-week minimum retention.
* The Lambda pricing page lists no free tier for MicroVMs.
Alien picks the MicroVM size from your `.limits()`. Without limits, it uses the 2 GiB baseline size (1 vCPU baseline, up to 4 vCPU and 8 GiB at peak). See [AWS sizes](/docs/infrastructure/sandbox/behavior#aws-sizes).
## Egress gateway [#egress-gateway]
`.egress("internet")` on AWS and Kubernetes runs two gateway replicas with 0.25 vCPU and 512 MiB each. AWS places them behind an internal Network Load Balancer, and Kubernetes uses a cluster Service. The gateway runs even when no sandbox does.
Budget for:
* Gateway compute, and any new cluster instances or disks it needs.
* The internal load balancer, including capacity-unit charges.
* NAT, public IPs, logs, and data transfer.
Spare cluster capacity may cover the gateway. On AWS, a stack without containers gets the default compute cluster for it, so include that in the plan review. Policies are kept in memory, so there's no database charge.
`.egress("none")` and the older egress values add no gateway.
See [EC2 pricing](https://aws.amazon.com/ec2/pricing/on-demand/) and [Elastic Load Balancing pricing](https://aws.amazon.com/elasticloadbalancing/pricing/).
## NAT [#nat]
Reuse existing NAT where you can. Count it once, then add the extra traffic.
See [AWS VPC pricing](https://aws.amazon.com/vpc/pricing/), [Google Cloud NAT pricing](https://cloud.google.com/nat/pricing), and [Azure NAT Gateway pricing](https://azure.microsoft.com/en-us/pricing/details/azure-nat-gateway/).
## Example [#example]
10,000 sandboxes a month, each running for 5 minutes on AWS at the default size (2 GB, 1 vCPU baseline), with no bursting and no pause:
```text
Running time: 10,000 × 300 s = 3,000,000 s
vCPU: 3,000,000 vCPU-s × $0.0000276944 = $83.08
Memory: 3,000,000 s × 2 GB = 6,000,000 GB-s × $0.0000036667 = $22.00
Compute total = $105.08
```
Bursting above baseline adds charges for the time spent above it. Pausing adds snapshot charges for each pause and resume.
For other platforms, convert the same usage to hours and multiply by the provider's rates:
```text
vCPU: 10,000 × 300 s / 3,600 = 833.33 vCPU-hours
Memory: 833.33 h × 2 GiB = 1,666.67 GiB-hours
```
This covers sandbox compute only. Add gateway compute, the load balancer, NAT and data transfer, logs, image storage, builds, and Alien's fee.
## Sources [#sources]
* [AWS Lambda pricing](https://aws.amazon.com/lambda/pricing/) (Lambda MicroVMs section)
* [Google Cloud Agent Platform pricing](https://cloud.google.com/products/gemini-enterprise-agent-platform/pricing)
* [Azure Container Apps pricing](https://azure.microsoft.com/en-us/pricing/details/container-apps/)
* [AWS VPC pricing](https://aws.amazon.com/vpc/pricing/)
* [Elastic Load Balancing pricing](https://aws.amazon.com/elasticloadbalancing/pricing/)
# Sandbox security (/docs/infrastructure/sandbox/security)
Alien treats code inside a [Sandbox](/docs/infrastructure/sandbox) as untrusted. The app that creates sandboxes is trusted within its permissions, and so are cloud and cluster administrators.
Give sandbox code only the secrets and files it may read: an allowed destination can receive anything that code can access.
## Isolation [#isolation]
| Platform | Boundary | Guest restrictions |
| ------------ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | Lambda MicroVM | The agent runs commands under a different identity from itself. `"internet"` egress is bounded by subnet routes and security groups outside the guest. |
| Google Cloud | Agent Platform sandbox | The provider enforces its container boundary. Direct `{ mode: "allow" }` egress doesn't exclude private destinations. |
| Azure | Container Apps sandbox | Registry and source builds use Alien's agent. Catalog images keep native exec. Azure enforces native egress policies outside the guest, but they don't prevent every DNS lookup. |
| Kubernetes | Pod under a sandboxed RuntimeClass | No service account token. NetworkPolicy restricts the pod from outside its processes. Needs an enforcing CNI. |
| Local | Docker container sharing the host kernel | For development. Not a boundary for hostile code. |
Kubernetes requires the `gvisor` RuntimeClass with a supported sandboxed handler. gVisor and Kata variants are accepted. The Alien Operator in the cluster refuses an ordinary container runtime.
## Users and privileges [#users-and-privileges]
Agent commands and jobs have no Linux capabilities and run with `no_new_privs`, which blocks gaining privileges through executable files.
| Platform | Agent | Commands and jobs | Image startup |
| ------------- | --------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| AWS | Root supervisor | UID 60000 for a root or absent image `USER`, otherwise the image's non-root UID | Image `USER`, once per boot. Absent `USER` means root when the sandbox has an egress connector. Explicit root requires `"internet"` or `"none"`. |
| Google Cloud | Root supervisor | UID 1000 for a root or absent image `USER`, otherwise the image's non-root UID | Image `USER`, once per boot. Absent means root. |
| Azure agent | Root supervisor | UID 1000 for a root or absent image `USER`, otherwise the image's non-root UID | Image `USER`, once per boot. Absent means root. Starts after the binding verifies the provider's network policy. |
| Azure catalog | No Alien agent | Image `USER`, which may be root. No jobs. | Native startup. The image's metadata can't be inspected. |
| Kubernetes | UID 65534 | UID 65534, all capabilities dropped | Explicit root is refused. The image must start the agent. |
| Local | No Alien agent | UID 65534 without `USER`, otherwise the image's non-root UID. All capabilities dropped. No jobs. | Explicit root is refused. The image command is replaced with a keepalive shell. |
On AWS, Google Cloud, and Azure agent images, a root startup process gets Docker's default capabilities except `NET_RAW`. Its effective, permitted, and bounding sets are `0xa80405fb`, and its ambient and inheritable sets are empty. It also runs with `no_new_privs`.
Non-root startup processes have no capabilities.
What root can and can't do:
* It can switch UIDs, change file ownership, and bind low ports.
* It has no `SYS_ADMIN`, `NET_ADMIN`, or `NET_RAW`. It can't mount filesystems or change the sandbox network.
* It can't overwrite the agent binary or `/opt/alien`. Private read-only mounts protect them before image startup, and protected parent mounts block replacing them by renaming a directory.
* It shares the agent's PID namespace, so it can signal commands and the agent and disrupt its own sandbox.
* It can read every guest file and change other writable files. File ownership inside the guest doesn't protect data from it. Keep secrets and credentials for your own systems out of the sandbox.
On AWS, an `"internet"` build plans the gateway before it's installed. Deployment requires the prepared gateway, and a missing gateway doesn't enable a fallback startup.
Google's [custom image requirements](https://docs.cloud.google.com/gemini-enterprise-agent-platform/scale/sandbox/custom-containers) say images must not require root. Alien's Google Cloud probes passed with root, but that is observed behavior, not a provider guarantee.
Sources: [image identity resolver](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-core/src/sandbox_identity.rs), [privilege drop](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-agent/src/privilege.rs), [platform checks](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-preflights/src/compile_time/sandbox_platform_support.rs).
### Device access [#device-access]
On AWS images with image-user startup and on Azure agent images, guest processes can't read, write, or create block devices, including existing device nodes. Loop devices, raw disk access through `blkid` or `fdisk`, and device mapper are unavailable. These platforms need cgroup2 with `CGROUP_BPF`, and startup fails without it.
Only these character devices are allowed:
| Device | Major:minor |
| --------- | ----------- |
| `null` | `1:3` |
| `zero` | `1:5` |
| `full` | `1:7` |
| `random` | `1:8` |
| `urandom` | `1:9` |
| `tty` | `5:0` |
| `console` | `5:1` |
| `ptmx` | `5:2` |
| `pts` | `136:*` |
Every other character device is denied, including `/dev/fuse`, `/dev/net/tun`, `/dev/kmsg`, `/dev/ttyS*`, and raw SCSI and NVMe devices. Terminal I/O and PTY allocation work. Provider processes keep their own device access.
A seccomp filter blocks `bpf` calls, so guest processes can't remove the device filter. This applies in AWS modes without a gateway too. Gateway mode also blocks IPv6 sockets and `io_uring`.
On Google Cloud, Alien installs no device or seccomp filter. Root keeps `MKNOD`, and device access depends on the provider runtime.
Sources: [device filter](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-agent/src/gateway_egress/guest_network.rs), [seccomp rules](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-agent/src/privilege.rs).
### Agent access [#agent-access]
| Platform | Who can call the agent |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | Guest callers are refused at every UID, including root. Socket marks tell them apart from provider processes, with or without a gateway. |
| Google Cloud | Local guest callers are refused at every UID. External provider calls work. |
| Azure agent | Guest root can read the per-boot bearer token and call its own sandbox's agent API. This grants no cloud credentials and no change to egress or isolation. |
Sources: [caller checks](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-agent/src/peer.rs), [file protection](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-agent/src/protected_files.rs), [Azure token scope](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-agent/src/native_token.rs).
### Azure transport [#azure-transport]
Azure registry and source builds use authenticated HTTPS to the Alien agent. The binding reads a per-boot credential through Azure's native file API, verifies the provider's network policy, and activates that boot. A fresh boot replaces the credential, and readiness detects a boot that hasn't been activated.
Retries never replay a command:
* After a 401, an untyped 404, or a connection failure before delivery, the binding checks for a changed credential or endpoint, refreshes, and retries once.
* An unchanged 401 fails without retrying.
* An agent 404 with `PATH_NOT_FOUND` or `JOB_NOT_FOUND` returns immediately and keeps the working route.
* Timeouts, broken responses, and server errors discard the cached route without replaying.
* Error bodies are read up to 4,096 bytes with a separate 60-second deadline. An oversized or incomplete body leaves the command's outcome unknown.
Session labels pick the transport when reconnecting across upgrades or rollbacks. Unknown labels fail closed, and an agent error never falls back to native exec. See the [Azure transport](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-bindings/src/providers/sandbox/azure_agent.rs).
Native Azure sessions, including new catalog sessions, don't have the agent restrictions above. A native root image can run with full capabilities, so mounts and network changes succeed there. Use a registry base image or a Dockerfile to get the agent.
## Egress path [#egress-path]
For `"internet"` sandboxes on AWS and Kubernetes:
| Platform | External enforcement |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| AWS | Sandbox subnets have no direct internet route. Security groups allow only the gateway path. The VPC resolver stays reachable. |
| Kubernetes | NetworkPolicy allows only gateway pods on TCP port 3128. A setup probe checks CNI enforcement before sandbox claims are accepted. |
| Azure, Google Cloud, Local | `"internet"` is refused at plan time. |
The gateway resolves destinations itself and checks every returned address. It rejects private, link-local, metadata, deployment, and container-network addresses. The gateway's own network rules also restrict its destinations.
The AWS agent checks its outbound path before accepting commands. A failed check reports `EGRESS_PATH_UNEXPECTED`.
## Gateway [#gateway]
Each sandbox gets an identity token valid for at most 24 hours. The token can't change policy. It's installed at runtime and never baked into the image.
Policy writes are signed. The signing key lives in the deployment's secrets vault, and workloads with `sandbox/management` can read it. Never pass it to sandbox code.
On AWS `"internet"` deployments, an explicit deny stops the deployment's management identity from reading the key, though it can write it. Kubernetes controllers and cluster administrators stay trusted.
The gateway holds only verification keys. Even a caller with the signing key can't authorize private destinations.
### Failures [#failures]
A surviving replica keeps serving existing policies when another stops. New connections sent to a stopped replica can stall until the load balancer removes it.
If every replica loses its state, deletion history is lost too. A sandbox can then restore an older signed policy until a newer management write or its token expires, which can take up to 24 hours.
## Permissions and remote callers [#permissions-and-remote-callers]
`sandbox/management` creates and terminates sandboxes and changes gateway policies. `sandbox/execute` runs code and moves files. Azure's execute role also includes lifecycle operations.
See [Permissions](/docs/infrastructure/sandbox#permissions).
Google Cloud management and execute grants bind at project scope. Use separate projects where deployments need separate sandbox access.
[Remote](/docs/infrastructure/sandbox/customer-sandboxes) callers hold cloud credentials directly. Their cloud grant bounds what they can do, including which images they can start.
The `alien.ts` declaration alone can't constrain them. See [Remote bindings](/docs/guide/remote-bindings).
## Instance metadata [#instance-metadata]
Cloud probes obtained no metadata credentials. These are observations from tested configurations, not guarantees about every metadata path.
| Platform | Guest access |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | Alien attaches no execution role. Gateway and `"none"` probes couldn't reach metadata. With unrestricted egress, IMDSv1 returned 401 and IMDSv2 listed only placement and tags. Role and instance-identity credential paths returned 404. |
| Google Cloud | Startup, child, and exec probes timed out on IPv4. IPv6 had no route, and `metadata.google.internal` didn't resolve. |
| Azure | Probes got `ECONNREFUSED` on IPv4 and `EAFNOSUPPORT` on IPv6. Native `{ mode: "allow" }` controls also got no metadata credentials. |
| Kubernetes | NetworkPolicy excludes `169.254.169.254`. The pod has no service account token. Needs an enforcing CNI. |
| Local | `"none"` disables networking. With `{ mode: "allow" }`, reachability depends on the Docker host's network. |
See the [metadata fixture](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-conformance/entrypoint/README.md) and the [AWS live guard](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-aws-clients/src/aws/lambda_microvms.rs).
## DNS on AWS [#dns-on-aws]
AWS security groups don't block the VPC resolver. Guest root can reach it if it bypasses guest-side DNS redirection, and a hostname can carry data. Treat VPC DNS as a possible outbound channel, including with `.egress("none")`.
The agent redirects DNS for the startup process, its child UIDs, and commands. Synthetic DNS answers don't mean the sandbox contacted an external resolver.
The [AWS qualification procedure](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-conformance/README.md) checks direct VPC queries separately, using an external DNS firewall to close the path security groups leave open. Don't infer DNS exclusion from a gateway allowlist alone.
## Verification [#verification]
| Check | Coverage |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Startup and image-user probes](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-conformance/entrypoint/README.md) | Root, absent, and non-root `USER` images: first-exec ordering, worker UID changes, exact capabilities, mount and network refusal, and metadata requests. |
| [Agent security checks](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/scripts/test-sandbox-agent-security.sh) | Caller attribution, protected files, capabilities, saved image users, legacy root refusal, guest routing, BPF refusal, device restrictions, and terminal I/O, in an isolated privileged container in CI. |
| [Egress conformance probes](https://github.com/alienplatform/alien/tree/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-conformance) | Paired probes for TCP, DNS, HTTP, HTTPS, proxy requests, TLS SNI, and HTTP Host. A failed positive control leaves the result unproven. |
| [Runtime policy probes](https://github.com/alienplatform/alien/tree/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-conformance/after) | Policy narrowing, private-address rejection, NAT address, gateway logs, and missing network attachments, on deployed fixtures. |
| [AWS live guard](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/.github/workflows/egress-deny-guard.yml) | Denied egress against an open control, and open internet access without metadata credentials. |
| [Kubernetes gateway test](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-infra/tests/kubernetes_sandbox_gateway_kind.rs) | Generated Helm chart, gateway reconciliation, and warm-pool pods on Kind with an enforcing CNI. |
| [CNI probe](https://github.com/alienplatform/alien/blob/55f912f30387bc7d32d1d03b8a1e4b92d473ae7f/crates/alien-sandbox-conformance/cni_probe.py) | Paired allowed and denied requests on the selected nodes. Checks network enforcement, not runtime isolation. |
Local harness tests validate the probes themselves. They don't establish cloud enforcement. Live results apply to the tested deployment, runtime, and network configuration.
# Storage API reference (/docs/infrastructure/storage/api)
The Storage API is the set of methods your code calls on a linked [Storage](/docs/infrastructure/storage) bucket, in the TypeScript, Python, and Rust SDKs. A **binding** is the connection details Alien injects into each workload [linked](/docs/infrastructure/using-resources#link) to the resource, which the SDK client reads.
A handle comes from `storage(name)` in `@alienplatform/sdk` (TypeScript) or `alienplatform` (Python), or `Bindings::from_env()?.storage(name).await?` in `alien-sdk` (Rust). `name` is the resource ID from `alien.ts`.
Creating the TypeScript or Python handle does no I/O. The binding resolves on the first call and is cached. If the workload isn't linked to the resource, the first call throws `BINDING_NOT_CONFIGURED`.
## Methods at a glance [#methods-at-a-glance]
| TypeScript | Python | Rust | What it does |
| ----------------------------------- | -------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------- |
| [`get(path)`](#get) | `get(path)`, `get_optional(path)` | `get`, `get_range`, `get_opts` | Read an object. |
| [`put(path, data, options?)`](#put) | `put(path, data, *, condition=None)` | `put`, `put_opts` | Write an object. |
| [`delete(path)`](#delete) | `delete(path)` | `delete` | Delete an object. |
| [`list(prefix?)`](#list) | `list(prefix=None)` | `list`, `list_with_delimiter` | List objects under a prefix. |
| [`head(path)`](#head) | | `head` | Read metadata and attributes without the body. |
| [`copy(from, to)`](#copy) | | `copy`, `copy_if_not_exists`, `rename` | Copy an object inside the bucket. |
| [`signedUrl(options)`](#signedurl) | `signed_url(method, path, expires_in)` | `presigned_get`, `presigned_put`, `presigned_delete` | Create a time-limited request a client can send without credentials. |
Paths are `/`-separated keys such as `reports/2026-10.json`. A path that isn't valid (empty segments, `.` or `..` segments, control characters) fails with `INVALID_INPUT` before any request.
## get [#get]
Reads the object at `path`.
```ts
const { data, meta, attributes } = await files.get("reports/2026-10.json")
const report = JSON.parse(data.toString("utf8")) // data is a Buffer
console.log(meta.size, meta.eTag, attributes.contentType)
```
```python
data = await files.get("reports/2026-10.json") # bytes, raises when missing
maybe = await files.get_optional("reports/2026-10.json") # bytes | None
```
```rust
let path = Path::from("reports/2026-10.json");
let result = files.get(&path).await?;
let meta = result.meta.clone();
let bytes = result.bytes().await?;
// First KiB only
let head = files.get_range(&path, 0..1024).await?;
```
| Parameter | Type | Description |
| --------- | -------- | -------------------------------- |
| `path` | `string` | Object key. At most 1,024 bytes. |
**Returns** [`StorageGetResult`](#storagegetresult): the bytes, the object's [`ObjectMeta`](#objectmeta), and its stored [`StorageObjectAttributes`](#storageobjectattributes). Python returns only the bytes.
**Errors:** `STORAGE_OBJECT_NOT_FOUND` when nothing exists at `path`.
## put [#put]
Writes `data` to `path`, replacing any object already there unless you pass a condition.
```ts
const { eTag, version } = await files.put("config.json", Buffer.from(JSON.stringify(config)))
// Store attributes with the bytes
await files.put("reports/q1.pdf", pdfBytes, {
attributes: {
contentType: "application/pdf",
contentDisposition: 'attachment; filename="q1.pdf"',
cacheControl: "public, max-age=3600",
metadata: { quarter: "q1" },
},
})
// Create only if absent
await files.put("locks/nightly", Buffer.from(runId), { condition: "absent" })
```
```python
await files.put("config.json", json.dumps(config).encode())
await files.put("locks/nightly", run_id.encode(), condition="absent")
```
```rust
use object_store::{Attribute, Attributes, PutMode, PutOptions, PutPayload};
files.put(&path, PutPayload::from(bytes)).await?;
files.put_opts(&path, PutPayload::from(bytes), PutOptions {
mode: PutMode::Create,
attributes: Attributes::from_iter([(Attribute::ContentType, "application/pdf".into())]),
..Default::default()
}).await?;
```
| Parameter | Type | Description |
| -------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `path` | `string` | Object key. At most 1,024 bytes. |
| `data` | `Buffer \| Uint8Array` | Object bytes. Encode strings and JSON yourself. |
| `options.condition` | `"absent"` | Write only when no object exists at `path`. The only accepted value. |
| `options.attributes` | [`StoragePutAttributes`](#storageputattributes) | Content type, disposition, encoding, language, cache control, and custom metadata to store with the object. |
**Returns** [`StoragePutResult`](#storageputresult): the provider's ETag and version for the written object, when the backend reports them. Python returns `None`.
**Errors:**
* `STORAGE_OBJECT_ALREADY_EXISTS` when `condition: "absent"` finds an existing object.
* `OPERATION_NOT_SUPPORTED` when the backend can't store a requested attribute: the local backend rejects all attributes, and Cloud Storage rejects `contentEncoding: "gzip"`. The write fails before any bytes are sent. See [Behavior and limits](/docs/infrastructure/storage/behavior#object-attributes).
* `INVALID_INPUT` for a `condition` other than `"absent"`.
## delete [#delete]
Deletes the object at `path`.
```ts
await files.delete("reports/old.json")
```
```python
await files.delete("reports/old.json")
```
```rust
files.delete(&path).await?;
```
On S3, Cloud Storage, and Blob Storage, deleting a missing object succeeds. The local backend returns `STORAGE_OBJECT_NOT_FOUND`.
## list [#list]
Lists objects whose key starts with `prefix`, or every object when you omit it.
```ts
for (const object of await files.list("reports/")) {
console.log(object.location, object.size, object.lastModified)
}
```
```python
for obj in await files.list("reports/"):
print(obj.location, obj.size, obj.last_modified)
```
```rust
use futures::StreamExt;
let mut stream = files.list(Some(&Path::from("reports")));
while let Some(meta) = stream.next().await {
let meta = meta?;
println!("{} {}", meta.location, meta.size);
}
// Directory-style listing: common prefixes plus objects at this level
let listing = files.list_with_delimiter(Some(&Path::from("reports"))).await?;
```
| Parameter | Type | Description |
| --------- | ------------------- | ------------------------------------------------------------ |
| `prefix` | `string` (optional) | Only objects under this prefix. Matching is by path segment. |
**Returns** `ObjectMeta[]` in TypeScript and `list[ObjectInfo]` in Python. TypeScript and Python read the whole listing into memory; Rust streams it.
`list` returns metadata only, because cloud list APIs don't return object attributes. Use [`head`](#head) for one object's attributes. Order isn't guaranteed.
## head [#head]
Reads an object's metadata and attributes without downloading the body. TypeScript and Rust only.
```ts
const { meta, attributes } = await files.head("reports/q1.pdf")
console.log(meta.size, meta.eTag, attributes.contentType)
```
```rust
let meta = files.head(&path).await?;
```
**Returns** [`StorageHeadResult`](#storageheadresult). Rust's `head` returns `ObjectMeta`; use `get_opts` with `head: true` to also get attributes.
**Errors:** `STORAGE_OBJECT_NOT_FOUND` when nothing exists at `path`.
## copy [#copy]
Copies an object to another key in the same bucket. TypeScript and Rust only.
```ts
await files.copy("reports/q1.json", "archive/q1.json")
```
```rust
files.copy(&from, &to).await?;
files.copy_if_not_exists(&from, &to).await?;
files.rename(&from, &to).await?; // copy, then delete
```
`copy` overwrites the destination. Rust's `rename` is a copy followed by a delete, and isn't atomic.
## signedUrl [#signedurl]
Creates a request that a client can send directly to the backend, without credentials, until it expires.
```ts
const request = await files.signedUrl({
method: "GET", // "GET" | "PUT" | "DELETE"
path: "reports/q1.json",
expiresIn: 3600, // seconds
})
// { url, method, headers }
```
```python
request = await files.signed_url("GET", "reports/q1.json", 3600)
print(request.url, request.method, request.headers)
```
```rust
use std::time::Duration;
let request = files.presigned_get(&path, Duration::from_secs(3600)).await?;
// Also presigned_put and presigned_delete
```
| Field | Type | Description |
| ----------- | ---------------------------- | ------------------------------------------------------------------------------- |
| `method` | `"GET" \| "PUT" \| "DELETE"` | The operation the request authorizes. Anything else fails with `INVALID_INPUT`. |
| `path` | `string` | Object key. |
| `expiresIn` | `number` | Validity in seconds. |
**Returns** [`PresignedRequest`](#presignedrequest). Send the request with the returned method and every returned header. On the local backend the URL uses a `local://` scheme and is only meaningful to Alien's local tooling.
## Rust-only operations [#rust-only-operations]
The Rust `Storage` handle implements the [`object_store`](https://docs.rs/object_store) `ObjectStore` trait, so it also has:
* range reads and conditional writes
* multipart uploads
* streaming and delimiter listing
* `rename`, `copy_if_not_exists`, and `rename_if_not_exists`
`get_base_dir()` and `get_url()` return the binding's base path and URL.
## Types [#types]
### ObjectMeta [#objectmeta]
```ts
interface ObjectMeta {
location: string // Object key
size: number // Size in bytes
lastModified: string // RFC 3339 timestamp
eTag?: string // Provider entity tag, when available
version?: string // Provider object version, when available
}
```
Python's `ObjectInfo` has the same fields in snake case: `location`, `size`, `last_modified`, `e_tag`, `version`.
### StorageObjectAttributes [#storageobjectattributes]
Returned by `get` and `head`.
```ts
interface StorageObjectAttributes {
contentType?: string
contentDisposition?: string
contentEncoding?: string
contentLanguage?: string
cacheControl?: string
storageClass?: string // Provider storage class, when reported
metadata: Record // User-defined metadata
}
```
### StoragePutAttributes [#storageputattributes]
Accepted by `put` under `options.attributes`. The writable subset of `StorageObjectAttributes`; `storageClass` is read-only.
```ts
interface StoragePutAttributes {
contentType?: string
contentDisposition?: string
contentEncoding?: string // Cloud Storage rejects "gzip"
contentLanguage?: string
cacheControl?: string
metadata?: Record
}
```
### StoragePutOptions [#storageputoptions]
```ts
interface StoragePutOptions {
condition?: "absent"
attributes?: StoragePutAttributes
}
```
### StorageGetResult [#storagegetresult]
```ts
interface StorageGetResult {
data: Buffer
meta: ObjectMeta
attributes: StorageObjectAttributes
}
```
### StorageHeadResult [#storageheadresult]
```ts
interface StorageHeadResult {
meta: ObjectMeta
attributes: StorageObjectAttributes
}
```
### StoragePutResult [#storageputresult]
```ts
interface StoragePutResult {
eTag?: string
version?: string
}
```
### PresignedRequest [#presignedrequest]
```ts
interface PresignedRequest {
url: string
method: string
headers: Record
}
```
Python's `SignedRequest` has the same fields.
## Errors [#errors]
| Code | Retryable | When |
| ------------------------------- | --------- | ----------------------------------------------------------------------- |
| `STORAGE_OBJECT_NOT_FOUND` | No | `get` or `head` on a missing object, or `delete` on the local backend. |
| `STORAGE_OBJECT_ALREADY_EXISTS` | No | `put` with `condition: "absent"` found an object. |
| `STORAGE_ACCESS_DENIED` | No | The workload's identity lacks permission. Check the permission profile. |
| `STORAGE_OPERATION_FAILED` | Yes | Any other backend failure. |
| `OPERATION_NOT_SUPPORTED` | No | The backend can't store a requested attribute. |
| `INVALID_INPUT` | No | Invalid path, condition, or signed URL method. |
| `BINDING_NOT_CONFIGURED` | No | The workload isn't linked to this resource. |
Errors are `AlienError` instances with `code`, `retryable`, and `context`. Error messages and context never include the object key or provider URLs.
# Storage behavior and limits (/docs/infrastructure/storage/behavior)
What a [Storage](/docs/infrastructure/storage) bucket guarantees on every platform, its limits, and how each cloud differs. Each cloud's native service does the storing, so the guarantees come from S3, Cloud Storage, and Blob Storage.
The binding is built on the Rust [`object_store`](https://github.com/apache/arrow-rs-object-store) crate, the object storage layer used by DataFusion and Delta Lake.
## Guarantees [#guarantees]
**Atomic writes.** A `put` either succeeds completely or fails. Readers never see a partial object.
**Read after write.** After a successful `put`, every `get` returns the new data. S3, Cloud Storage, and Blob Storage are all strongly consistent for object reads.
**List after write.** After a `put` or `delete`, `list` reflects the change.
**Conditional create.** `put` with `condition: "absent"` is atomic. If two callers create the same key at once, exactly one succeeds and the other gets `STORAGE_OBJECT_ALREADY_EXISTS`.
**Durability.** S3 and Cloud Storage are designed for 99.999999999% (11 nines) durability. Azure storage accounts use locally redundant storage (LRS), also designed for at least 11 nines.
On Kubernetes, Alien doesn't create the bucket, so these guarantees are those of the service in the external binding. MinIO and other S3-compatible stores may differ.
## Limits [#limits]
| Limit | Value |
| ------------------- | ------------------------------------------------------------------------------------- |
| Object size | 5 TB. Larger than 5 GB needs a multipart upload, which only the Rust API exposes. |
| Object key | 1,024 bytes of UTF-8 |
| Key characters | UTF-8 without control characters. No empty, `.`, or `..` segments. |
| Multipart part size | At least 5 MiB, except the last part |
| Resource ID | 54 characters (it becomes part of the bucket or container name) |
| Lifecycle rules | AWS: 1,000 rules, `days` ≥ 1. GCP: 1,000 prefixes. Azure: 100 rules, `days` ≤ 99,999. |
Alien adds no rate limits of its own. Each cloud's request limits apply; see [per-cloud details](#per-cloud-details).
## Operation semantics [#operation-semantics]
**List order.** `list` doesn't guarantee any order. Sort on the client if you need one.
**Prefix matching.** `list` matches by path segment: prefix `reports/2026` matches `reports/2026/q1.json`, not `reports/2026-old/q1.json`.
**Copy.** `copy` overwrites the destination. Rust's `rename` is a copy and then a delete, and isn't atomic.
**Multipart uploads (Rust).** Parts upload concurrently in any order and the object appears atomically when the upload completes.
**ETags and versions.** `ObjectMeta` carries the provider's ETag and version when the backend reports them, and `put` returns them for the object it wrote.
**Deleting a missing object** succeeds on S3, Cloud Storage, and Blob Storage. The local backend returns `STORAGE_OBJECT_NOT_FOUND`.
### Object attributes [#object-attributes]
`put` can store these attributes:
* content type, disposition, encoding, and language
* cache control
* custom metadata
`get` and `head` return them, plus the storage class when the provider reports it. `list` doesn't return attributes, because cloud list APIs don't.
When a backend can't store a requested attribute, the write fails with `OPERATION_NOT_SUPPORTED` before any bytes are sent. It never stores the object without the attribute. Two backends reject some attributes:
* **Local** rejects every attribute. The filesystem has nowhere to keep them.
* **Cloud Storage** rejects `contentEncoding: "gzip"`. It decompresses gzip objects when serving them and drops the response length that byte-exact reads depend on. Other encodings, such as `br`, work.
### Signed URLs [#signed-urls]
A signed URL is a full request: URL, method, and headers. Clients must send the headers too.
Signed URLs work on every backend. On the local backend the URL uses a `local://` scheme that only Alien's local tooling understands.
### Lifecycle rules [#lifecycle-rules]
Each rule deletes objects older than `days`, under `prefix` when you set one. Overlapping rules are allowed; S3 applies the shorter expiration.
The build checks rules against the target cloud's limits (`STORAGE_LIFECYCLE_RULES_INVALID`), so an invalid rule fails before release instead of during initial setup:
| Cloud | Checks |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------ |
| AWS | At most 1,000 rules. `days` from 1 to 2,147,483,647. Prefix at most 1,024 bytes and only characters XML allows. |
| GCP | At most 1,000 rules with a prefix. `days` at most 2,147,483,647. `days: 0` expires objects at the first midnight UTC after creation. |
| Azure | At most 100 rules. `days` from 0 to 99,999. Checked for frozen Storage only. |
### CORS [#cors]
`.corsAllowedOrigins(origins)` lets browsers on those origins `GET` and `HEAD` objects through signed URLs. The rule:
* allows any request header
* exposes `ETag`, `Content-Range`, and `Accept-Ranges`
* is cached for an hour
Signed URLs carry their own credentials and don't use cookies, so `*` is reasonable for a private bucket.
The rule is applied to S3 buckets that the CloudFormation setup template creates. Other setups don't apply it yet.
## Encryption [#encryption]
Every bucket is encrypted at rest. Without `.encryptionKey()`, each cloud uses its own provider-managed key (on S3, SSE-S3).
With `.encryptionKey(key)`, the bucket encrypts with the customer's [Key](/docs/infrastructure/key):
| Cloud | How |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | S3 default encryption is SSE-KMS with the key. |
| GCP | The bucket's default KMS key is set, and the Cloud Storage service agent gets `roles/cloudkms.cryptoKeyEncrypterDecrypter` on it. |
| Azure | The storage account uses the key through a user-assigned identity and the key's versionless ID, so a new key version is picked up automatically. The key covers the whole account, so a stack that encrypts Storage with a key can contain only one Storage resource. |
Rules for an encrypted bucket:
* Storage and its Key must both be `"frozen"`.
* The key can't be added, removed, or changed after the bucket exists.
* Only AWS, GCP, and Azure support it.
* If the customer revokes the key, reads and writes fail until they restore it. See [Customer-managed keys](/docs/guide/customer-managed-keys/in-a-deployment#revoke-and-restore).
## Triggers [#triggers]
A Worker with a `storage` trigger runs when objects change. Delivery is at least once.
| Platform | Wiring | Events |
| ---------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| AWS | S3 event notification to the Lambda function | `created` (`s3:ObjectCreated:*`), `deleted` (`s3:ObjectRemoved:*`) |
| GCP | Cloud Storage notification to a Pub/Sub topic that pushes to Cloud Run | `created`, `deleted`, `metadataUpdated`, `archived` |
| Azure | Event Grid subscription on the storage account, delivered through a Service Bus queue | `created`, `deleted`, `tierChanged` |
| Kubernetes | Not supported | |
| Local | Alien's local trigger service watches the directory | `created`, `deleted` |
Use `created` and `deleted` for code that runs on every cloud. Filter by key with the `prefix` option of `onStorageEvent`.
The handler's `event.eventType` is one of `created`, `deleted`, `copied`, `metadataUpdated`, `restored`, `tierChanged`, or `unknown`.
On AWS, updating or removing a trigger changes only the trigger's own entry in the bucket's notification configuration. Other entries stay.
A Storage with [notifications](#notifications) can't be a storage trigger source. The build fails; consume its queue instead.
## Notifications [#notifications]
`.notify(queue, rule)` puts a `StorageEvent` into a Queue for each new object that matches the rule. See [Notify a queue](/docs/infrastructure/storage#notify-a-queue) for the declaration and a consumer.
### Rules [#rules]
A rule matches a key that starts with `prefix` and ends with `suffix`. Omit either to match everything on that side.
* Matching is literal and case sensitive. `*` and `\` have no special meaning.
* `events` defaults to `["created"]`, the only event type. Overwrites count as creations.
* Rules on one Storage must not overlap: no key may match two rules. `uploads/` with `.json` and `uploads/` with `.csv` are fine. `uploads/` and `uploads/images/` with no suffix overlap.
* To send the same objects to two queues, consume one queue and fan out in your Worker.
The build checks every rule, on every platform:
| Check | Limit |
| ----------------- | --------------------------------------------------------------------------------------- |
| Rules per Storage | 10, none overlapping |
| `prefix` | 1 to 512 bytes of UTF-8 |
| `suffix` | 1 to 128 bytes of UTF-8 |
| Characters | No control characters |
| Target | A Queue in the same stack |
| Lifecycle | Storage and Queue both `"frozen"` or both `"live"` |
| Platforms | AWS, Google Cloud, Azure, and Local. Kubernetes and Linux servers refuse notifications. |
### Delivery [#delivery]
* **At least once.** The same object can arrive more than once. Make processing idempotent, for example by recording `objectKey` and `etag`.
* **Unordered.** Events for different objects, or for successive overwrites of one object, can arrive in any order.
* **The object is the record.** Read the current object rather than trusting event fields to describe it.
* **Only new writes.** A rule sees objects written after it takes effect. Existing objects aren't replayed.
* **Rule changes take time.** Clouds apply new rules asynchronously, which can take several minutes on S3. To catch objects written during a change, list the Storage afterwards.
Other messages in the same queue arrive unchanged, and so does a notification that can't be parsed.
### Frozen and live [#frozen-and-live]
The bucket's lifecycle decides who owns its rules. See [Frozen and live](/docs/guide/frozen-and-live).
* **Frozen.** Initial setup creates the rules. Adding, changing, or removing one needs initial setup again: the release reports `DEPLOYMENT_SETUP_REQUIRED` until the customer's admin [reruns it](/docs/guide/frozen-and-live#changes-that-need-initial-setup-again).
* **Live.** Alien creates and removes the rules on every release, and touches only the entries it created. A changed rule's new version is created before the old one is removed, so delivery continues, possibly twice for a short while.
On AWS, once a frozen bucket declares notifications, initial setup manages the bucket's whole notification configuration. Notifications added to that bucket outside Alien, such as Lambda, SQS, SNS, or EventBridge targets, are removed the next time the admin reruns initial setup. Use a rule and a queue instead.
Live rules use management permissions Alien adds automatically. If the stack overrides [management permissions](/docs/guide/permissions#permission-layers), keep `storage/provision` and `queue/provision` on AWS, `storage/trigger-management` on Azure, and `queue/storage-notifications` on Google Cloud. Workers that only consume the queue need no extra permissions.
### Per cloud [#per-cloud]
| | AWS | Google Cloud | Azure | Local |
| ------------- | --------- | ------------------------ | ----------------------------------------- | ------------------------------- |
| Path | S3 to SQS | Cloud Storage to Pub/Sub | Blob Storage to Event Grid to Service Bus | File watcher to the local queue |
| Prefix filter | In S3 | In Cloud Storage | In Event Grid | In the watcher |
| Suffix filter | In S3 | In the consumer | In Event Grid | In the watcher |
* **AWS.** Each matching object costs one SQS message. Objects no rule matches cost nothing. SSE-SQS encrypted queues work.
* **Google Cloud.** Cloud Storage filters only by prefix, so every object under a rule's prefix is published, and the Worker runtime or `receive()` drops the ones no suffix matches. Each still costs a Pub/Sub message, and a `receive(max)` call can return fewer messages than are waiting. Pick prefixes that are selective on their own.
* **Google Cloud quota.** A bucket allows 10 notification configs, including ones made outside Alien. A Storage uses one per distinct queue and prefix.
* **Azure.** All rules share the storage account's system topic. Alien never deletes the topic or subscriptions it didn't create.
### Event payload [#event-payload]
Every cloud delivers the same `StorageEvent`:
| Field | Present | Notes |
| -------------------------------------- | ------------------------------- | -------------------------------------------------------------- |
| `eventType` | Always | `"created"` |
| `bucketName` | Always | The bucket or container name |
| `objectKey` | Always | The literal key, decoded on AWS |
| `timestamp` | Always | RFC 3339, UTC |
| `size` | AWS, Google Cloud, Azure, Local | Bytes |
| `etag` | AWS, Google Cloud, Azure | In the provider's format. Compare ETags only within one cloud. |
| `contentType` | Google Cloud, Azure | |
| `versionId`, `currentTier`, `metadata` | Google Cloud | Generation, storage class, custom metadata |
Code that runs on every cloud should rely only on the fields marked "Always".
The helpers are `storageEventFromMessage` in TypeScript and `storage_event_from_message` in Python and Rust. Each fails with `INVALID_STORAGE_EVENT_MESSAGE` when a message isn't a storage event. None acknowledges the message: with `receive()`, acknowledge after processing succeeds.
### Local development [#local-development]
Under [`alien dev`](/docs/guide/local-development), a watcher delivers notifications into the local queue, even while the consuming Worker is stopped.
* Each rule keeps a cursor in `.alien/`. Restarting replays nothing already delivered, and objects written while stopped arrive once it's back.
* A new rule starts from the objects that exist when it starts.
* The watcher sees the latest state of each object. An object overwritten twice between scans produces one event.
## Per-cloud details [#per-cloud-details]
### AWS (S3) [#aws-s3]
* Request rates scale automatically per prefix: at least 3,500 PUT/COPY/POST/DELETE and 5,500 GET/HEAD requests per second per prefix.
* Versioning, lifecycle rules, CORS, and public read are all supported.
* `publicRead` turns off the bucket's public access block and adds a bucket policy that allows anonymous `GetObject`.
### GCP (Cloud Storage) [#gcp-cloud-storage]
* A bucket starts at about 1,000 object writes and 5,000 object reads per second and scales up as load grows. Ramp up gradually, doubling at most every 20 minutes, for sustained high rates.
* IAM changes can take a minute or more to apply.
* `publicRead` turns on uniform bucket-level access and grants `allUsers` the Storage Object Viewer role (`roles/storage.objectViewer`), which allows reading and listing objects.
* `contentEncoding: "gzip"` is rejected (see [Object attributes](#object-attributes)).
### Azure (Blob Storage) [#azure-blob-storage]
* Each Storage resource is a container in one storage account per stack. The account is Standard, LRS, Hot tier.
* A single blob serves up to about 500 requests per second.
* `publicRead` sets the container's access level to `blob`: anonymous reads of objects, not of the container listing.
* **Versioning** is an account-level setting on Azure. When the setup template creates the storage account, versioning is on for the whole account if any Storage resource asks for it. When Alien creates the container directly, `versioning` is ignored with a warning.
* **Lifecycle rules** become a storage management policy when the setup template creates the Storage. When Alien creates the container directly, they're ignored with a warning.
### Kubernetes [#kubernetes]
Alien creates nothing. Point each Storage resource at S3, an S3-compatible store (set `endpoint`, `accessKeyId`, and `secretAccessKey`), Cloud Storage, or Blob Storage in the Helm values. See [Kubernetes](/docs/infrastructure/platforms/kubernetes#connect-the-customers-infrastructure).
### Local [#local]
* Objects are files in a directory under `.alien/`. Durability is that of the local disk.
* Object attributes aren't supported.
* Deleting a missing object returns `STORAGE_OBJECT_NOT_FOUND`.
## Design decisions [#design-decisions]
**Built on `object_store`.** A widely used foundation instead of a custom abstraction. Rust code gets the full `ObjectStore` trait.
**No Alien rate limiting.** Each cloud's own limits apply. S3 scales per prefix; Cloud Storage needs a ramp. The difference is documented rather than hidden.
**Fail instead of dropping attributes.** A write that can't keep an attribute fails, so a stored object always has the attributes you asked for.
**Permissive keys.** Unlike [KV](/docs/infrastructure/kv), Storage accepts any valid UTF-8 path, to match how file paths are used.
# Customer buckets for a hosted product (/docs/infrastructure/storage/customer-buckets)
Customer buckets (bring your own bucket) keep a customer's files at rest in their own AWS, Google Cloud, or Azure account, while your backend stays hosted. This guide adds that storage setting to a product you host.
It's the hosted-product counterpart of [Deployment UX](/docs/deployment-ux): instead of deploying your software, the customer's admin connects one bucket.
If you already deploy into the customer's cloud, see [When you already deploy to the customer](#when-you-already-deploy-to-the-customer).
* The customer's admin runs the initial setup once. It creates the bucket and a dedicated identity that can reach only that bucket.
* Your backend asks Alien for short-lived credentials for that bucket, then calls the cloud's storage API directly with `@alienplatform/bindings`.
* Object data goes from your backend to the bucket, not through Alien. Your backend never receives the admin's credentials.
## Enable buckets [#enable-buckets]
This happens once. In the dashboard, on the **Buckets** page, **Connect your application** creates a server API key, and **Connect a bucket** creates a setup link. From the CLI:
```bash
alien projects capabilities enable buckets
alien api-keys create --for remote-bindings --description production
```
Save the key as `ALIEN_REMOTE_BINDINGS_KEY` on your backend.
Creating setup links and reading connection status need your regular Alien API key (`ALIEN_API_KEY`), used by the client from [Set up a client](/docs/deployment-ux/deploy-button#set-up-a-client).
## Add a Connect storage button [#add-a-connect-storage-button]
This runs on your server, in the route your **Connect storage** button calls. Create a [setup link](/docs/deployment-ux/setup-links) with the `bucket` item for the customer's **external ID** (your own stable ID for them), and send the customer to it.
The link opens the [deployment portal](/docs/deployment-ux/deployment-portal), where their admin runs initial setup. The request is `POST /v1/deployment-groups/setup-links`:
TypeScript:
```ts title="lib/connect-storage.ts"
import { alien, PROJECT } from "./alien"
export async function connectStorageLink(customer: { id: string; slug: string }) {
const link = await alien.setupLinks.create({
project: PROJECT,
externalId: customer.id,
name: customer.slug,
setupItems: [{ item: "bucket", required: true }],
})
return link.deploymentLink
}
```
Python:
```python title="connect_storage.py"
from alien_client import PROJECT, alien
def connect_storage_link(customer_id: str, customer_slug: str) -> str:
r = alien.post(
"/v1/deployment-groups/setup-links",
json={
"project": PROJECT,
"externalId": customer_id,
"name": customer_slug,
"setupItems": [{"item": "bucket", "required": True}],
},
)
r.raise_for_status()
return r.json()["deploymentLink"]
```
Rust:
```rust title="src/connect_storage.rs"
use serde_json::{json, Value};
use crate::alien::{client, API, PROJECT};
pub async fn connect_storage_link(
customer_id: &str,
customer_slug: &str,
) -> reqwest::Result {
let link: Value = client()
.post(format!("{API}/v1/deployment-groups/setup-links"))
.json(&json!({
"project": PROJECT,
"externalId": customer_id,
"name": customer_slug,
"setupItems": [{ "item": "bucket", "required": true }],
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(link["deploymentLink"].as_str().unwrap_or_default().to_owned())
}
```
In the portal, the customer sees **Data storage**: "Choose where your data is stored." Their admin runs initial setup in their own account, which creates:
| Cloud | Created |
| ------------ | ------------------------------- |
| AWS | An Amazon S3 bucket |
| Google Cloud | A Cloud Storage bucket |
| Azure | An Azure Blob Storage container |
Initial setup also creates a remote bindings identity with read, write, list, and delete access to that bucket only. It always creates a new bucket. It doesn't attach a bucket the customer already has.
## Read and write [#read-and-write]
This runs on your server, wherever your product stores or reads a customer's file. Address the customer by external ID with `Bindings.forRemoteCustomer`. The bucket's resource name is `storage`:
```ts title="lib/customer-bucket.ts"
import { Bindings } from "@alienplatform/bindings"
// One Bindings value per customer. Credentials refresh automatically.
export async function customerBucket(customerId: string) {
const bindings = await Bindings.forRemoteCustomer({
project: "docproc",
externalId: customerId,
token: process.env.ALIEN_REMOTE_BINDINGS_KEY!,
})
return bindings.storage("storage")
}
export async function saveUpload(customerId: string, docId: string, file: Uint8Array) {
const bucket = await customerBucket(customerId)
await bucket.put(`uploads/${docId}.pdf`, file, {
attributes: { contentType: "application/pdf" },
})
const { data } = await bucket.get(`uploads/${docId}.pdf`)
return data.byteLength
}
```
The handle supports `get`, `put`, `head`, `list(prefix)`, and `delete`. `put` with `condition: "absent"` creates an object only if it doesn't exist yet. Copy and signed URLs aren't available, so your backend proxies uploads and downloads.
Keep one `Bindings` value per customer and reuse it: it refreshes credentials as they expire. Create a new one after you rotate the API key.
`@alienplatform/bindings` loads a native module. It runs on Linux (x64 and arm64, glibc) and macOS.
## Show the connection [#show-the-connection]
This runs on your server, when your settings page loads. Read `capabilities.buckets.state` from the capability overview, `GET /v1/projects/{idOrName}/project-capabilities/overview`, with the `capability` helper from [Customer-managed keys](/docs/guide/customer-managed-keys/add-to-your-product#show-the-connection):
TypeScript:
```ts title="lib/storage-status.ts"
import { capability } from "./capability"
export async function storageStatus(customerId: string) {
return (await capability(customerId, "buckets"))?.state ?? "not-connected"
}
```
Python:
```python title="storage_status.py"
from capability import capability
def storage_status(customer_id: str) -> str:
buckets = capability(customer_id, "buckets")
return buckets["state"] if buckets else "not-connected"
```
Rust:
```rust title="src/storage_status.rs"
use crate::capability::capability;
pub async fn storage_status(customer_id: &str) -> reqwest::Result {
let buckets = capability(customer_id, "buckets").await?;
let state = buckets.and_then(|b| b["state"].as_str().map(str::to_owned));
Ok(state.unwrap_or_else(|| "not-connected".into()))
}
```
The states are `not-connected`, `setting-up`, `connected`, `needs-attention`, and `revoked`.
If the customer deletes the bucket or the identity, your calls fail. Treat that as the customer's decision: show "Storage disconnected" and stop writing.
## When you already deploy to the customer [#when-you-already-deploy-to-the-customer]
If you already deploy your own stack to the customer's cloud, declare the bucket in `alien.ts` with `remoteAccess: true` and address it by deployment with `Bindings.forRemoteDeployment({ deploymentId, token })`. See [Remote bindings](/docs/guide/remote-bindings) and the [examples](/docs/examples).
## Test it [#test-it]
Try it as your own test customer before you ship:
```bash
alien onboard "Test customer" --external-id org_test --setup-items storage
```
Open the printed link and run initial setup in a cloud account you control. Then, for each cloud you offer:
1. Call `saveUpload` with `org_test` and confirm the object appears in the bucket in the cloud console.
2. Read it back, list the prefix, and delete it.
3. Delete the setup stack or resources in the cloud account, and confirm your calls fail and your UI shows "Storage disconnected".
## Next [#next]
* [Remote Sandboxes](/docs/infrastructure/sandbox/customer-sandboxes): run code next to that data
* [Customer-managed keys](/docs/guide/customer-managed-keys): put the customer's key on your own records
* [Remote bindings](/docs/guide/remote-bindings)
# Storage (/docs/infrastructure/storage)
Storage is an object storage bucket in the customer's cloud for your code: files, uploads, reports, exports, and backups up to 5 TB per object. One API reads and writes objects by key (a path such as `reports/2026-10.json`) on S3, Cloud Storage, Blob Storage, or a local directory.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const files = new alien.Storage("files")
.versioning(true)
.lifecycleRules([{ days: 30, prefix: "tmp/" }])
.build()
const api = new alien.Worker("api")
.code({ type: "source", src: "./api", toolchain: { type: "typescript" } })
.link(files)
.permissions("execution")
.build()
export default new alien.Stack("acme")
.add(files, "frozen")
.add(api, "live")
.permissions({
profiles: { execution: { files: ["storage/data-read", "storage/data-write"] } },
})
.build()
```
In this stack:
* `.link(files)` connects the bucket to the `api` Worker, the **workload** (the running code) that uses it. Alien injects the bucket's connection details, its **binding**, into the Worker, read by the SDK client.
* The `execution` [permission profile](/docs/guide/permissions), the named set of cloud permissions a workload runs with, grants `storage/data-read` and `storage/data-write`.
* `"frozen"` means the customer's admin creates the bucket during initial setup. `"live"` resources, like the Worker, are created and updated by Alien on every release. See [Frozen and live](/docs/guide/frozen-and-live).
The `api` Worker writes and reads objects by key:
```ts title="api/src/files.ts"
import { storage } from "@alienplatform/sdk"
const files = storage("files")
await files.put("reports/2026-10.json", Buffer.from(JSON.stringify({ ok: true })))
const { data } = await files.get("reports/2026-10.json")
```
## Platforms [#platforms]
`files` becomes a bucket in the customer's cloud:
| Platform | Backing service | Created by |
| ---------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| AWS | Amazon S3 bucket | Alien |
| GCP | Cloud Storage bucket | Alien |
| Azure | Blob container in the stack's storage account | Alien |
| Kubernetes | S3, an S3-compatible store such as MinIO, Cloud Storage, or Blob Storage | The customer, through an [external binding](/docs/infrastructure/platforms/kubernetes#connect-the-customers-infrastructure) |
| Machines | Same services as Kubernetes | The customer, through an external binding. The resource must be `"frozen"`. See [Machines](/docs/infrastructure/platforms/machines). |
| Local | Directory under `.alien/` | Alien |
* **Kubernetes:** Alien doesn't create a bucket. The build fails unless each Storage resource has an external binding of type `storage`. Guarantees then depend on the service the customer points it at.
* **Azure:** every Storage resource in a stack is a container inside one storage account (Standard, LRS, Hot tier) that Alien adds to the stack.
## Frozen or live [#frozen-or-live]
Storage can be `"frozen"` (created once during initial setup by the customer's admin) or `"live"` (created and updated by Alien on releases). With an encryption key it must be `"frozen"`. See [Frozen and live](/docs/guide/frozen-and-live).
The resource ID becomes part of the bucket name (`-`). It must be lowercase letters, digits, and hyphens, and at most 54 characters.
## Alternatives [#alternatives]
Storage isn't a database. For small values looked up by key, with TTL and compare-and-swap, use [KV](/docs/infrastructure/kv). For relational data, use [Postgres](/docs/infrastructure/postgres).
## Options [#options]
| Method | Type | Default | Notes |
| ------------------------------ | ---------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.publicRead(on)` | `boolean` | `false` | Anonymous read access to every object. |
| `.versioning(on)` | `boolean` | `false` | Keeps prior versions of overwritten and deleted objects. See [Azure](/docs/infrastructure/storage/behavior#azure-blob-storage) for limits. |
| `.lifecycleRules(rules)` | `{ days: number, prefix?: string }[]` | `[]` | Deletes objects older than `days`, optionally only under `prefix`. Validated per cloud at build time. See [Lifecycle rules](/docs/infrastructure/storage/behavior#lifecycle-rules). |
| `.corsAllowedOrigins(origins)` | `string[]` | `[]` | Browser origins allowed to `GET` and `HEAD` objects through signed URLs. See [CORS](/docs/infrastructure/storage/behavior#cors). |
| `.encryptionKey(key)` | `Key` | provider-managed key | Encrypts objects with a customer-managed [Key](/docs/infrastructure/key). Storage and Key must both be `"frozen"`. Can't change after the bucket exists. |
| `.notify(queue, rule?)` | `Queue`, `{ prefix?, suffix?, events? }` | none | Puts an event into a [Queue](/docs/infrastructure/queue) for each new object that matches. Call it once per rule. See [Notify a queue](#notify-a-queue). |
| `.enabled(input)` | boolean stack input | always on | Creates the resource only when the input is `true`. |
The ID and the encryption key are fixed once the bucket exists. Other options can change on a later release.
## Use it from code [#use-it-from-code]
Get a handle with `storage(name)`. The name is the resource ID from `alien.ts`. Creating the handle does no I/O; the binding resolves on the first call.
```ts title="src/files.ts"
import { storage } from "@alienplatform/sdk"
const files = storage("files")
await files.put("reports/2026-10.json", Buffer.from(JSON.stringify({ ok: true })), {
attributes: { contentType: "application/json" },
})
const { data, meta, attributes } = await files.get("reports/2026-10.json")
const report = JSON.parse(data.toString("utf8"))
for (const object of await files.list("reports/")) {
console.log(object.location, object.size)
}
```
```python title="files.py"
import json
from alienplatform import storage
files = storage("files")
await files.put("reports/2026-10.json", json.dumps({"ok": True}).encode())
report = json.loads(await files.get("reports/2026-10.json"))
maybe = await files.get_optional("reports/missing.json") # None when absent
for obj in await files.list("reports/"):
print(obj.location, obj.size)
```
```rust title="src/files.rs"
use alien_sdk::Bindings;
use object_store::{path::Path, ObjectStore, PutPayload};
let bindings = Bindings::from_env()?;
let files = bindings.storage("files").await?;
let path = Path::from("reports/2026-10.json");
files.put(&path, PutPayload::from(br#"{"ok":true}"#.to_vec())).await?;
let bytes = files.get(&path).await?.bytes().await?;
```
The full method list is in the [API reference](/docs/infrastructure/storage/api).
## Common patterns [#common-patterns]
### Signed URLs [#signed-urls]
A browser or another service uploads or downloads an object directly. The bytes don't pass through your code.
```ts
const upload = await files.signedUrl({ method: "PUT", path: "uploads/avatar.png", expiresIn: 900 })
// Send upload.url, upload.method and upload.headers to the client.
await fetch(upload.url, { method: upload.method, headers: upload.headers, body: imageBytes })
```
The result is a full request, not a bare URL. Send the returned headers with it. For a browser to read objects through signed `GET` URLs from another origin, set `.corsAllowedOrigins()`.
### Write once [#write-once]
`condition: "absent"` writes only when nothing exists at the path. If two callers race, exactly one succeeds; the other gets `STORAGE_OBJECT_ALREADY_EXISTS`.
Use it for idempotent outputs and basic locks:
```ts
import { AlienError } from "@alienplatform/sdk"
try {
await files.put(`exports/${jobId}.csv`, csv, { condition: "absent" })
} catch (error) {
if (!(error instanceof AlienError) || error.code !== "STORAGE_OBJECT_ALREADY_EXISTS") throw error
// Another run already wrote this export.
}
```
### React to new objects [#react-to-new-objects]
A Worker can run when objects are created or deleted. Declare the trigger in `alien.ts` and register a handler:
```ts title="alien.ts"
const uploads = new alien.Storage("uploads").build()
const processor = new alien.Worker("processor")
.code({ type: "source", src: "./processor", toolchain: { type: "typescript" } })
.trigger({ type: "storage", storage: uploads.ref(), events: ["created"] })
.build()
```
```ts title="processor/src/index.ts"
import { onStorageEvent, storage } from "@alienplatform/sdk"
const uploads = storage("uploads")
onStorageEvent("uploads", async event => {
const { data } = await uploads.get(event.objectKey)
// ...
}, { prefix: "incoming/" })
```
Delivery is at least once, so make handlers idempotent. Per-cloud wiring is in [Behavior and limits](/docs/infrastructure/storage/behavior#triggers).
### Notify a queue [#notify-a-queue]
A Storage can put an event into a [Queue](/docs/infrastructure/queue) every time an object is created or overwritten. Each `.notify()` call adds a rule:
```ts title="alien.ts"
const work = new alien.Queue("work").build()
const objects = new alien.Storage("objects")
.notify(work, { prefix: "uploads/", suffix: ".json" })
.notify(work, { prefix: "uploads/", suffix: ".csv" })
.build()
const ingest = new alien.Worker("ingest")
.code({ type: "source", src: "./ingest", toolchain: { type: "typescript" } })
.link(objects)
.trigger({ type: "queue", queue: work.ref(), batch: { maxMessages: 10, maxWindowSeconds: 5 } })
.permissions("execution")
.build()
```
Consume the queue like any other: a Worker [queue trigger](/docs/infrastructure/worker/events-and-triggers#storage-events-in-a-queue), single or batched, or a `receive()` loop. `storageEventFromMessage` reads the event from a message:
```ts title="ingest/src/index.ts"
import { onQueueMessage, storage, storageEventFromMessage } from "@alienplatform/sdk"
onQueueMessage("work", async message => {
const event = await storageEventFromMessage(message)
const { data } = await storage("objects").get(event.objectKey)
// ...
})
```
The event carries the bucket and key, so read the object itself. Delivery is at least once and unordered.
Choose notifications over a storage trigger when you want batches, a suffix filter, or a consumer that pulls with `receive()`. A storage trigger also handles `deleted` events. A Storage can't do both: one with notifications can't be a Worker's storage trigger source.
Rules, limits, frozen and live buckets, and per-cloud delivery are in [Notifications](/docs/infrastructure/storage/behavior#notifications).
### Read from your hosted software [#read-from-your-hosted-software]
To read or write a customer's bucket from your own backend, publish it as a [remote binding](/docs/guide/remote-bindings). Remote storage supports `get`, `put`, `delete`, `list`, and `head`.
For a hosted product that keeps each customer's files in a bucket in their cloud, without a deployment, see [Customer buckets](/docs/infrastructure/storage/customer-buckets).
## Permissions [#permissions]
Grant a permission set in the workload's profile, naming the resource:
| Set | Grants |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `storage/data-read` | Get, head, and list objects. |
| `storage/data-write` | Read and write objects, including put, delete, and copy. |
| `storage/remote-data-write` | Object operations on this one bucket from your hosted software through a remote binding. |
| `storage/trigger-management` | Attaching and removing event notification wiring for Worker storage triggers, and for notifications on a live Azure Storage. |
| `storage/provision`, `storage/management`, `storage/heartbeat` | Used by initial setup and by Alien's management identity. |
| `key/native-storage` | Using the customer-managed Key the bucket is encrypted with. |
When a workload's profile doesn't name the bucket, `.link()` and a storage trigger grant `storage/data-write`. Naming the bucket in the profile replaces that default. See [Permissions](/docs/guide/permissions).
## Next [#next]
* [API reference](/docs/infrastructure/storage/api)
* [Behavior and limits](/docs/infrastructure/storage/behavior)
* [Pricing](/docs/infrastructure/storage/pricing)
* [Key](/docs/infrastructure/key)
# Storage pricing (/docs/infrastructure/storage/pricing)
100 GB with a million reads a month costs about $3, on the customer's own cloud bill. They pay their provider for the bucket, requests, and data transfer; Alien adds no per-request or per-GB charge. For Alien's own pricing, see [Pricing](/pricing).
Prices are list prices as of October 2026 in AWS us-east-1, GCP us-central1, and Azure East US. Official pages are under [Sources](#sources).
## Storage [#storage]
| Provider | Service | Per GB-month | Notes |
| -------- | ---------------------- | ------------ | ---------------------------------------- |
| AWS | S3 Standard | $0.023 | First 50 TB. $0.022 for the next 450 TB. |
| GCP | Cloud Storage Standard | $0.020 | Regional bucket. |
| Azure | Blob Storage Hot, LRS | $0.0208 | First 50 TB. Alien creates LRS accounts. |
## Requests [#requests]
| Provider | Writes and lists (per 1,000) | Reads (per 1,000) |
| ------------------ | ------------------------------ | ------------------- |
| AWS S3 | $0.005 (PUT, COPY, POST, LIST) | $0.0004 (GET, HEAD) |
| GCP Cloud Storage | $0.005 (Class A) | $0.0004 (Class B) |
| Azure Blob Storage | $0.005 (write) | $0.0004 (read) |
Signed URL requests are billed the same as any other request.
## Data transfer [#data-transfer]
Uploads into the bucket are free on all three providers. Downloads to the internet:
| Provider | First 100 GB per month | Next, up to 10 TB per month |
| -------- | ----------------------------------------------------------- | --------------------------- |
| AWS | Free, shared across all AWS services | $0.09 per GB |
| GCP | Free from us-central1, us-east1, and us-west1 (Always Free) | $0.12 per GiB |
| Azure | Free | $0.087 per GB |
Transfer between a bucket and compute in the same region is free on AWS and GCP.
## Example [#example]
100 GB stored, 1 million reads, and 100,000 writes in a month, with traffic staying in the region:
| Provider | Storage | Reads | Writes | Total |
| ------------------ | ------- | ----- | ------ | --------- |
| AWS S3 | $2.30 | $0.40 | $0.50 | **$3.20** |
| GCP Cloud Storage | $2.00 | $0.40 | $0.50 | **$2.90** |
| Azure Blob Storage | $2.08 | $0.40 | $0.50 | **$2.98** |
Versioning keeps every overwritten and deleted version, and each version is billed as stored data. If objects change often, pair versioning with lifecycle rules.
## Free tiers [#free-tiers]
* **AWS:** no S3-specific allowance for accounts created since July 15, 2025. They get up to $200 in AWS Free Tier credits instead, usable within 12 months.
* **GCP:** 5 GB-months of regional Standard storage in some US regions, 5,000 Class A and 50,000 Class B operations per month, with no time limit.
* **Azure:** 5 GB of LRS Hot blob storage for 12 months on new accounts.
## Sources [#sources]
* [Amazon S3 pricing](https://aws.amazon.com/s3/pricing/)
* [Cloud Storage pricing](https://cloud.google.com/storage/pricing)
* [Azure Blob Storage pricing](https://azure.microsoft.com/en-us/pricing/details/storage/blobs/)
# Supporting resources (/docs/infrastructure/supporting-resources)
Supporting resources are the image registry, build jobs, and cloud identities behind a stack: ArtifactRegistry, Build, and ServiceAccount. Most stacks never declare them, because Alien adds what a stack needs.
You declare one directly when a **workload** (a Worker, Container, or Daemon) pushes or pulls images itself, starts build jobs in the customer's cloud, or needs an identity with an inline permission set.
| Resource | What it is | AWS | GCP | Azure |
| ------------------------------------- | ------------------------ | --------- | ----------------- | ------------------- |
| [ArtifactRegistry](#artifactregistry) | Container image registry | ECR | Artifact Registry | Container Registry |
| [Build](#build) | On-demand build jobs | CodeBuild | Cloud Build | Container Apps jobs |
| [ServiceAccount](#serviceaccount) | Workload identity | IAM role | Service account | Managed identity |
All three are always `"frozen"`: the customer's admin creates them during initial setup (see [Frozen and live](/docs/guide/frozen-and-live)). None has `.enabled()`.
[Names in deployment status](#names-in-deployment-status) lists what every resource Alien adds is called in the portal and the dashboard.
## ArtifactRegistry [#artifactregistry]
An ArtifactRegistry is a set of container image repositories in the customer's cloud, with shared identity and access control. You provision one per stack, and Alien's build and release flow creates repositories inside it and pushes images into them.
```text
ArtifactRegistry("images") provisioned once: the cloud registry and its pull and push identities
├── images/app-a repositories created as images are built and pushed
├── images/app-b
└── images/app-c
```
Declare one when your stack builds and runs container images, for example for [Container](/docs/infrastructure/container), [Daemon](/docs/infrastructure/daemon), or [Build](#build) resources, and you want Alien to own the registry, its IAM, and cross-account image distribution.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const images = new alien.ArtifactRegistry("images")
.replicationRegions(["us-west-2", "eu-west-1"]) // AWS only
.build()
export default new alien.Stack("acme")
.add(images, "frozen")
.build()
```
The management identity can still create repositories inside a frozen registry and manage access.
### Options [#options]
| Member | Type | Default | Notes |
| -------------------------------- | ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `new alien.ArtifactRegistry(id)` | `string` | | `[A-Za-z0-9-_]`, up to 64 characters. |
| `.replicationRegions(regions)` | `string[]` | `[]` | AWS only. Replicates images pushed in the home region to these regions, so compute in another region pulls from a nearby endpoint. Lambda needs this when it runs in a different region from the registry. |
ArtifactRegistry has no SDK client, and your code doesn't resolve it at runtime. Alien's release flow and Build resources that link it push images into it.
To push or pull with the cloud's own tools from a workload, grant a permission set:
```ts title="alien.ts"
.permissions({
profiles: { builder: { images: ["artifact-registry/push"] } },
})
```
### Registry platforms [#registry-platforms]
The registry is created during initial setup. Repositories are created inside it as images are built and released.
| Platform | Backing service | What initial setup creates | What creating repository `my-app` does |
| ---------- | ------------------------ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| AWS | Amazon ECR | IAM roles for pull and push. ECR has no registry resource to create. | Creates an ECR repository named `{prefix}-my-app` with `CreateRepository`. |
| GCP | Artifact Registry | A Docker repository, plus service accounts for pull and push. | Nothing. Image paths are created on first push. Returns the full image path. |
| Azure | Azure Container Registry | A container registry. | Nothing. ACR creates the repository on first push. Returns the repository URI. |
| Kubernetes | An external registry | Nothing. Supply an external binding to your registry. | |
| Machines | | Not provisioned. | |
| Local | A local registry | Nothing. It connects to the local registry `alien dev` runs. | Pushes a marker manifest to create the repository. |
A repository is addressed by its routable name: the full, cloud-specific path used for credentials and cross-account grants.
| Platform | Routable name for `my-app` |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | `{prefix}-my-app`. Underscores allowed. |
| GCP | `{project}/{repository}/my-app`. Each `/`-separated part uses lowercase letters, digits, dots, underscores, and hyphens, and starts and ends with a letter or digit. Other names fail with `INVALID_INPUT` instead of being rewritten. |
| Azure | `my-app` |
| Local | `{binding}/my-app`, for example `artifacts/my-app` |
A project id like `prj_abc123` works on every cloud.
### Registry guarantees [#registry-guarantees]
* **Short-lived credentials.** Generated credentials carry the requested permission, pull or push-and-pull, and expire. On AWS they're scoped to one repository. On GCP they reach the whole Artifact Registry repository, since every name is an image path inside it.
* **Isolated cross-account grants.** A grant names specific accounts, service accounts, and service types. Granting pull access to one account doesn't affect any other.
* **No widened grants on GCP.** Artifact Registry has no IAM below a repository, so a grant on one name would reach every name's images. Alien refuses such a grant with `OPERATION_NOT_SUPPORTED` instead of widening it.
* Only the registry's own pull and push service accounts can be named.
* To give tenants separate access, use a separate ArtifactRegistry per tenant.
* **Old images stay readable after a rename.** On AWS, when the registry's repository prefix changes, images released under the old prefix stay readable and keep their cross-account grants. Pushes to the old prefix are refused.
### Cross-account access [#cross-account-access]
On AWS and GCP, Alien can let other cloud accounts pull images from the registry, for example when compute in a customer's account runs images released from yours.
| Platform | How access is granted |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | ECR repository policies naming AWS account ids and IAM role ARNs. Lambda workers in another account also need permissions on their execution role. |
| GCP | IAM bindings on the Artifact Registry repository, naming project numbers and service account emails. Covers every name in the repository. |
| Azure | Not supported through Alien. Cross-subscription access isn't exposed. |
Grants stay cloud-specific because AWS and GCP use different identity models, and a single abstraction would lose one side.
Replication is AWS-only for a similar reason. ECR has native cross-region replication; GCP offers multi-region repositories through a different mechanism.
### Registry limits [#registry-limits]
| Limit | Value | Notes |
| -------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| Resource id | `[A-Za-z0-9-_]`, up to 64 characters | |
| Credential lifetime, AWS | Up to 12 hours | From `GetAuthorizationToken`, a base64 username and password pair. Longer requests are capped. |
| Credential lifetime, GCP | Up to 1 hour | A short-lived access token from service account impersonation. Longer requests are capped. |
| Credential lifetime, Azure | About 5 minutes | A stateless exchange: Entra ID token, ACR refresh token, scoped access token. No persistent tokens or scope maps are created. |
| Images per repository | 100,000 on ECR | An ECR quota. GCP and Azure have higher or no documented limits. |
| Cross-account pull | AWS and GCP | Not on Azure. On GCP a grant covers the whole repository. |
| Replication | AWS only | |
Per-cloud details:
* **AWS.** Pulls aren't throttled per repository for authenticated requests.
* **GCP.** No presigned URLs: layers are fetched through the registry API with a bearer token. Alien uses only the Docker format.
* **Azure.** The tier sets limits:
* Basic: 10 GB, 10 writes a minute, 1,000 reads a minute. Alien's direct provisioning creates Basic.
* Standard: 100 GB, 100 writes, 3,000 reads.
* Premium: 500 GB, 2,000 writes, 10,000 reads. The Terraform setup template creates Premium with admin access turned off.
* **Local.** A local registry with basic auth and no cross-account access, for development and tests.
### Pricing [#pricing]
The customer pays the cloud provider for the registry on their own cloud bill. Alien lists ArtifactRegistry as a free resource on [Pricing](/pricing).
Prices are list prices as of October 2026 in AWS us-east-1, GCP us-central1, and Azure East US.
| Cloud | Service | Price | Notes |
| ----- | ----------------- | ----------------------------------- | -------------------------------------------------------------- |
| AWS | ECR | $0.10 per GB a month | No base fee. |
| GCP | Artifact Registry | $0.10 per GB a month | First 0.5 GB free. |
| Azure | ACR Basic | $0.1666 a day, about $5.07 a month | 10 GB included, then $0.10 per GB. |
| Azure | ACR Standard | $0.6666 a day, about $20.28 a month | 100 GB included, then $0.10 per GB. |
| Azure | ACR Premium | $1.6666 a day, about $50.69 a month | 500 GB included, then $0.10 per GB. Geo-replication available. |
For 10 GB of images a month:
| Cloud | Monthly cost |
| --------------------- | ------------------------------------------- |
| AWS ECR | $1.00 |
| GCP Artifact Registry | $0.95 (9.5 GB billed after the free 0.5 GB) |
| Azure ACR Basic | $5.07 flat |
| Azure ACR Premium | $50.69 flat |
* Pulls within the same region are usually free or close to it. Cross-region and internet egress follow standard data transfer pricing.
* On AWS, replicated images are billed as storage in each region.
* ACR Basic's 10 writes a minute can slow down release pipelines.
Sources: [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/), [Artifact Registry pricing](https://cloud.google.com/artifact-registry/pricing), [Azure Container Registry pricing](https://azure.microsoft.com/en-us/pricing/details/container-registry/).
### Registry permissions [#registry-permissions]
| Set | Grants |
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `artifact-registry/pull` | Pulling images and reading repository metadata. |
| `artifact-registry/push` | Pushing and pulling images. |
| `artifact-registry/provision` | Creating the registry during initial setup. |
| `artifact-registry/management`, `artifact-registry/heartbeat` | Used by the management identity to manage repositories and access, and to check health. |
## Build [#build]
A Build resource is a build project in the customer's cloud that runs a bash script in a container image on demand, for compiling, testing, or packaging code.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const images = new alien.ArtifactRegistry("images").build()
const builder = new alien.Build("builder")
.computeType("large")
.environment({ TARGET: "release" })
.link(images)
.permissions("builder")
.build()
export default new alien.Stack("acme")
.add(images, "frozen")
.add(builder, "frozen")
.permissions({
profiles: { builder: { images: ["artifact-registry/push"] } },
})
.build()
```
### Build options [#build-options]
| Method | Type | Default | Notes |
| ----------------------- | --------------------------------------------- | ---------- | ------------------------------------------------------------------------- |
| `.permissions(profile)` | `string` | required | [Permission profile](/docs/reference/permissions) the build runs with. |
| `.computeType(type)` | `"small" \| "medium" \| "large" \| "x-large"` | `"medium"` | Each cloud maps it to its own sizes. See [Compute types](#compute-types). |
| `.environment(vars)` | `Record` | `{}` | Variables for every run. |
| `.link(resource)` | `Resource` | none | Repeatable. |
### Start a build [#start-a-build]
Build has no TypeScript or Python binding. Rust workloads start runs through the `alien-bindings` build binding:
| Method | Notes |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `start_build(config)` | `config` has `image`, `script` (bash), `environment`, `timeout_seconds`, `compute_type`, and optional log `monitoring`. Returns a `BuildExecution`. |
| `get_build_status(build_id)` | `BuildExecution` with status `QUEUED`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCELLED`, or `TIMED_OUT`. |
| `stop_build(build_id)` | Cancels a run. |
The resource output `identifier` is the provider's project id (ARN on AWS, project id on GCP, resource id on Azure). See the [Rust SDK](/docs/reference/sdk/rust).
### Build platforms [#build-platforms]
| Platform | Provisions |
| ---------- | ------------------- |
| AWS | CodeBuild project |
| GCP | Cloud Build |
| Azure | Container Apps jobs |
| Kubernetes | Kubernetes Jobs |
| Machines | Not supported |
| Local | Not supported |
### Compute types [#compute-types]
| `computeType` | AWS CodeBuild | GCP Cloud Build | Azure Container Apps job |
| ------------- | ------------------------ | --------------- | ------------------------ |
| `small` | `BUILD_GENERAL1_SMALL` | `E2_MEDIUM` | 0.25 vCPU, 0.5 GiB |
| `medium` | `BUILD_GENERAL1_MEDIUM` | `E2_MEDIUM` | 0.5 vCPU, 1 GiB |
| `large` | `BUILD_GENERAL1_LARGE` | `E2_HIGHCPU_8` | 1 vCPU, 2 GiB |
| `x-large` | `BUILD_GENERAL1_2XLARGE` | `E2_HIGHCPU_32` | 2 vCPU, 4 GiB |
On AWS the CodeBuild project takes the compute type from `.computeType()`. On GCP and Azure each run applies `compute_type` from its config.
### Runs [#runs]
* **Script.** Each run executes `script` with bash. Alien wraps it to forward logs to the optional `monitoring` OTLP endpoint (path `/v1/logs` by default, TLS on and verified by default).
* **Image.** On GCP, Azure, and Kubernetes the run uses `image` from the run's config. On AWS the CodeBuild project uses the Alien builder image (`ghcr.io/alienplatform/alien-builder`) set at initial setup.
* **Timeout.** `timeout_seconds` bounds the run on GCP, Azure (replica timeout), and Kubernetes (`activeDeadlineSeconds`). On AWS the project's CodeBuild timeout applies.
* **Retries.** Kubernetes doesn't retry a failed run (`backoffLimit: 0`). Azure allows one replica retry.
* **Logs.** On AWS, logs go to CloudWatch Logs. On GCP, to Cloud Logging only.
### Build permissions [#build-permissions]
| Set | Grants |
| -------------------------------------------------------- | -------------------------------------------------- |
| `build/execute` | Starting, stopping, and monitoring runs. |
| `build/logs-and-artifacts` | Lets the build write logs and build reports. |
| `build/provision`, `build/management`, `build/heartbeat` | Used by initial setup and the management identity. |
## ServiceAccount [#serviceaccount]
A ServiceAccount is a non-human cloud identity that workloads run as: an IAM role, a GCP service account, or an Azure managed identity.
You rarely declare one. Alien creates a ServiceAccount for each [permission profile](/docs/guide/permissions) in the stack, and every workload with `.permissions("")` runs as it:
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const files = new alien.Storage("files").build()
const api = new alien.Worker("api")
.code({ type: "source", src: "./api", toolchain: { type: "typescript" } })
.permissions("execution")
.build()
// Alien adds a ServiceAccount for the "execution" profile
export default new alien.Stack("acme")
.add(files, "frozen")
.add(api, "live")
.permissions({ profiles: { execution: { files: ["storage/data-read"] } } })
.build()
```
To declare one directly with an inline permission set:
```ts title="alien.ts"
const reader = new alien.ServiceAccount("reader")
.stackPermissionSet({
id: "custom/read-objects",
description: "Read objects from every bucket in the stack",
platforms: {
aws: [{ grant: { actions: ["s3:GetObject"] }, binding: { stack: { resources: ["*"] } } }],
},
})
.build()
```
### ServiceAccount options [#serviceaccount-options]
| Method | Type | Default | Notes |
| -------------------------- | --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `.stackPermissionSet(set)` | `PermissionSet` | none | Repeatable. Inline sets only, with `id`, `description`, and `platforms`. Built-in set names go in permission profiles instead. |
ServiceAccount has no SDK binding. Workloads get its credentials automatically from the platform.
### ServiceAccount platforms [#serviceaccount-platforms]
| Platform | Provisions |
| ---------- | -------------------------------------------------------- |
| AWS | IAM role |
| GCP | Service account |
| Azure | User-assigned managed identity |
| Kubernetes | Nothing. Uses ServiceAccounts created by the Helm chart. |
| Machines | Not supported |
| Local | Local identity record |
### Outputs [#outputs]
| Field | AWS | GCP | Azure |
| ------------ | --------- | ------------------------- | ---------------------------- |
| `identity` | Role ARN | Service account email | Managed identity client id |
| `resourceId` | Role name | Service account unique id | Managed identity resource id |
### Identity behavior [#identity-behavior]
* **One identity per profile.** A profile named `execution` becomes a ServiceAccount with id `execution-sa`. The cloud identity is named after the stack prefix and that id.
* **Stack-wide grants.** Permission sets under `"*"` in the profile attach to the identity directly. On AWS they're an inline policy on the role.
* **Resource-scoped grants.** Sets scoped to one resource are applied by that resource when it's created, so the grant names the exact bucket, queue, or secret.
* **Grants survive their profile.** A frozen ServiceAccount keeps the grants captured during initial setup, even after the profile that produced them is removed from `alien.ts`. Changing them takes the customer's admin running initial setup again.
* **Impersonation.** `service-account/impersonate` lets one identity act as another. On AWS it grants `sts:AssumeRole` on roles with the stack prefix, and the target role's trust policy lists the callers.
| Limit | Value |
| ---------------------- | ------------------------------------------------ |
| Id | `[A-Za-z0-9-_]`, up to 64 characters. Immutable. |
| GCP service account id | Up to 30 characters, a GCP limit |
| Lifecycle | Frozen only |
### ServiceAccount permissions [#serviceaccount-permissions]
| Set | Grants |
| --------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `service-account/impersonate` | Acting as this identity from another workload. |
| `service-account/provision` | Creating roles, service accounts, and managed identities. Used by initial setup. |
| `service-account/management`, `service-account/heartbeat` | Used by the management identity. |
## Names in deployment status [#names-in-deployment-status]
The deployment portal and the dashboard show each resource Alien adds under a built-in name, next to its ID. A stack gets only the ones it needs, each in the category for its type (see [Categories](/docs/deployment-ux/deployment-progress#categories)).
### AWS [#aws]
| Name | ID | Category |
| --------------------------- | ------------------------ | --------------------- |
| Network | `default-network` | Networking and access |
| Management identity | `management` | Networking and access |
| Service account (*profile*) | `-sa` | Networking and access |
| Remote resource access | `access` | Networking and access |
| Compute cluster | `compute` | Compute |
| Sandbox egress gateway | `sandbox-egress-gateway` | Compute |
| Application secrets | `secrets` | Secrets and keys |
| Deployer secrets | `deployer-secrets` | Secrets and keys |
### Google Cloud [#google-cloud]
| Name | ID | Category |
| ------------------------------------ | ---------------------------------------------- | --------------------- |
| Network | `default-network` | Networking and access |
| Management identity | `management` | Networking and access |
| Service account (*profile*) | `-sa` | Networking and access |
| Remote resource access | `access` | Networking and access |
| *Service* API, such as Cloud Run API | `enable-`, such as `enable-cloud-run` | Networking and access |
| Agent Platform engine (*sandbox*) | `-engine` | Networking and access |
| Compute cluster | `compute` | Compute |
| Application secrets | `secrets` | Secrets and keys |
| Deployer secrets | `deployer-secrets` | Secrets and keys |
Google Cloud APIs appear only when the stack needs them: Artifact Registry API, Cloud Build API, Cloud Firestore API, Cloud KMS API, Cloud Quotas API, Cloud Resource Manager API, Cloud Run API, Cloud SQL Admin API, Cloud Storage API, Compute Engine API, IAM Service Account Credentials API, Identity and Access Management (IAM) API, Kubernetes Engine API, Pub/Sub API, Secret Manager API, Security Token Service API, and Vertex AI API.
### Azure [#azure]
| Name | ID | Category |
| ------------------------------------------------------------ | ---------------------------------------- | --------------------- |
| Network | `default-network` | Networking and access |
| Resource group | `default-resource-group` | Networking and access |
| Storage account | `default-storage-account` | Networking and access |
| Container Apps environment | `default-container-env` | Networking and access |
| Service Bus namespace | `default-service-bus-namespace` | Networking and access |
| Management identity | `management` | Networking and access |
| Service account (*profile*) | `-sa` | Networking and access |
| Remote resource access | `access` | Networking and access |
| Microsoft.*Service* provider, such as Microsoft.App provider | `enable-`, such as `enable-app` | Networking and access |
| Compute cluster | `compute` | Compute |
| Application secrets | `secrets` | Secrets and keys |
| Deployer secrets | `deployer-secrets` | Secrets and keys |
Provider registrations appear only when the stack needs them: Microsoft.App, Microsoft.ContainerRegistry, Microsoft.ContainerService, Microsoft.DBforPostgreSQL, Microsoft.EventGrid, Microsoft.KeyVault, Microsoft.Network, Microsoft.ServiceBus, and Microsoft.Storage.
### Kubernetes [#kubernetes]
| Name | ID | Category |
| ---------------------- | ------------------------ | --------------------- |
| Kubernetes cluster | `kubernetes` | Networking and access |
| Sandbox egress gateway | `sandbox-egress-gateway` | Compute |
Kubernetes on a cloud can also get a Network. See [Network](/docs/infrastructure/network#when-its-added).
### Your machines [#your-machines]
| Name | ID | Category |
| --------------- | --------- | -------- |
| Compute cluster | `compute` | Compute |
## Next [#next]
* [Permissions](/docs/guide/permissions): profiles and the identities they become
* [Releases](/docs/guide/releases): how images are built and published
* [Permission sets](/docs/reference/permissions): every built-in set by resource
# Using resources (/docs/infrastructure/using-resources)
A resource is a piece of cloud infrastructure your code uses, such as a bucket, a key-value store, or a queue. Every resource works the same way: you declare it in `alien.ts`, connect it to the code that uses it, and call it from that code.
```ts title="alien.ts"
const documents = new alien.Storage("documents").versioning(true).build()
const index = new alien.Kv("index").build()
const processor = new alien.Worker("processor")
.code({ type: "source", src: "./processor", toolchain: { type: "typescript" } })
.link(documents)
.link(index)
.permissions("processor")
.build()
```
```ts title="processor/src/index.ts"
import { kv, storage } from "@alienplatform/sdk"
const documents = storage("documents")
const index = kv("index")
async function main() {
const file = await documents.get("contracts/2026/msa.pdf")
await index.setJson("doc:contracts/2026/msa.pdf", { bytes: file.data.length })
}
void main()
```
Alien creates the native service on each platform. On AWS, `documents` is an S3 bucket and `index` is a DynamoDB table. On Google Cloud, the same code reads Cloud Storage and writes Firestore.
## Declare [#declare]
Every builder follows one pattern: `new alien.X(id)`, chained setters, then `.build()`. Add the result to the [stack](/docs/reference/stack) with `.add(resource, "frozen")` or `.add(resource, "live")`, which decides who creates and changes it. See [Frozen or live](#frozen-or-live).
## Link [#link]
A **link** connects a resource to a **workload**, the code that uses it. `.link()` does two things:
* It injects the resource's connection details into the workload's environment.
* It grants access through the workload's [permission profile](/docs/guide/permissions), the named set of cloud permissions the workload runs with. Name the resource in the profile to choose the exact access.
Code can only reach resources it is linked to. The cloud enforces this through the workload's identity.
## Call from code [#call-from-code]
A **binding** is how your code calls a linked resource: Alien injects the resource's connection details into the workload, and a client from `@alienplatform/sdk` reads them. Each client factory takes the resource id from `alien.ts`, as in `storage("documents")` above.
Creating a binding does no network I/O. The first call resolves the resource and authenticates with the workload's cloud identity: an IAM role on AWS, a service account on Google Cloud, a managed identity on Azure. Your code holds no keys or connection strings.
Source-built TypeScript bundles load the native bindings as CommonJS, which doesn't allow top-level `await`. Put startup code in a function, as above. Handlers registered with `command()` or `onStorageEvent()` are already async.
Containers and Daemons use the same bindings. A TypeScript service that isn't built from source imports them from `@alienplatform/bindings`. Python (`alienplatform`) and Rust (`alien-sdk`) wrap the same core; see the [SDK reference](/docs/reference/sdk/typescript).
### What a link injects [#what-a-link-injects]
Linking a resource injects `ALIEN__BINDING` into the workload: a JSON description of the provisioned service. The id is uppercased and hyphens become underscores. See [Environment variables](/docs/reference/environment-variables).
### Cloud SDKs [#cloud-sdks]
The Alien SDK is optional. The binding carries the native identifiers, so you can use the cloud's own SDK for features Alien doesn't wrap, such as DynamoDB streams or Pub/Sub ordering keys:
```ts title="processor/src/report.ts"
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"
const binding = JSON.parse(process.env.ALIEN_DOCUMENTS_BINDING!)
// { service: "s3", bucketName: "...", region: "us-east-1" }
const s3 = new S3Client({ region: binding.region })
await s3.send(new GetObjectCommand({ Bucket: binding.bucketName, Key: "report.json" }))
```
Both approaches work in the same app. Some resources, such as [Email](/docs/infrastructure/email), have no Alien client and are used this way.
## Frozen or live [#frozen-or-live]
Each resource is added to the stack as `"frozen"` or `"live"`. The difference is who creates and changes it:
* **Frozen**: the customer's admin creates it once, during initial setup, with their own credentials. Alien can't change it afterward.
* **Live**: Alien creates and updates it on every release, through the deployment's **management identity**, a scoped identity in the customer's account.
See [Frozen and live resources](/docs/guide/frozen-and-live). `Stack.add()` always takes the lifecycle explicitly. Each resource accepts:
| Resource | Lifecycle |
| --------------------------------------- | -------------------- |
| Worker, Container, Daemon | Live |
| ComputeCluster | Frozen |
| Sandbox | Frozen (Live on AWS) |
| Storage, Kv, Queue, Vault, Postgres, AI | Frozen or live |
| Key, Email, Network | Frozen |
| ArtifactRegistry, Build, ServiceAccount | Frozen |
Resources Alien adds for you, such as the Network and a ServiceAccount per permission profile, are frozen. ArtifactRegistry and Key are frozen, but the management identity can still manage what's inside them, such as repositories and access grants.
## Optional resources [#optional-resources]
`.enabled(input)` creates a resource only when a boolean [input](/docs/guide/inputs-and-secrets) the admin answers at initial setup is true, for example an `AI` resource that only some customers turn on. See [Inputs and secrets](/docs/guide/inputs-and-secrets#optional-resources).
A frozen gated resource keeps the answer given at creation. A live gated resource follows later edits to the input, and turning it off deletes the resource and its data.
## Common to every resource [#common-to-every-resource]
| Member | Notes |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `new alien.X(id)` | Ids match `[A-Za-z0-9-_]`, at most 64 characters. Container and Sandbox ids must be DNS-compatible (lowercase letters, digits, hyphens). |
| `.build()` | Validates the config against its schema and returns a `Resource`. Throws on invalid input. |
| `.enabled(input)` | Creates the resource only when a boolean [stack input](/docs/reference/stack#inputs) is true. Not on ArtifactRegistry, Build, ServiceAccount, or ComputeCluster. |
| `static any()` | Returns the resource type, for permission targets that cover every resource of that type. |
| `resource.ref()` | Returns `{ type, id }`, used by triggers and links. |
## Common patterns [#common-patterns]
**Hand out an upload URL.** Your code creates a signed URL, and the customer's browser uploads straight into their bucket. The file never passes through your cloud:
```ts title="processor/src/index.ts"
const upload = await documents.signedUrl({ method: "PUT", path: "inbox/new.pdf", expiresIn: 900 })
```
**React to new data.** A Worker trigger runs a handler for each event:
* `{ type: "storage", storage, events: ["created"] }` with `onStorageEvent`
* `{ type: "queue", queue }` with `onQueueMessage`
* `{ type: "schedule", cron }` with `onCronEvent`
Delivery can repeat, so make handlers safe to run twice.
**Call another service in the stack.** Link a Container to the Worker, then read its private URL:
```ts title="processor/src/index.ts"
import { container } from "@alienplatform/sdk"
const ocrUrl = await container("ocr").getInternalUrl()
const res = await fetch(`${ocrUrl}/extract`, { method: "POST", body: new Uint8Array(file.data) })
```
## Local development [#local-development]
`alien dev` runs your stack on your machine with no cloud credentials. Most resources get a local equivalent behind the same SDK calls, so code that runs locally runs unchanged in a customer's cloud.
AI uses your own provider key locally, and Email is AWS only. See [Local development](/docs/guide/local-development).
## Next [#next]
* [Choose compute](/docs/infrastructure/compute): pick a Worker, Container, or Daemon for each workload
* [Frozen and live resources](/docs/guide/frozen-and-live): who creates and changes each resource
* [Permissions](/docs/guide/permissions): choose exactly what each workload can do
* [Remote bindings](/docs/guide/remote-bindings): use a resource from your own backend
# Vault API reference (/docs/infrastructure/vault/api)
The Vault API is the set of methods your code calls on a linked [Vault](/docs/infrastructure/vault), in the TypeScript, Python, and Rust SDKs. A **binding** is the connection details Alien injects into each workload [linked](/docs/infrastructure/using-resources#link) to the resource, which the SDK client reads.
A handle comes from these calls, where `name` is the resource ID from `alien.ts`:
* TypeScript: `vault(name)` from `@alienplatform/sdk`
* Python: `vault(name)` from `alienplatform`
* Rust: `Bindings::from_env()?.vault(name).await?` from `alien-sdk`
Creating the handle in TypeScript or Python does no I/O. The binding resolves on the first call and is cached. Secret values aren't cached: every `get` reads from the secret service.
## Methods at a glance [#methods-at-a-glance]
| TypeScript | Python | Rust | What it does |
| ------------------------ | ------------------ | ---------------------------------------------- | --------------------------------------------- |
| [`get`, `getJson`](#get) | `get(name)` | `get_secret(name)` | Read a secret. |
| [`put`, `putJson`](#put) | `put(name, value)` | `set_secret(name, value)` | Create or overwrite a secret. |
| [`delete`](#delete) | `delete(name)` | `delete_secret(name)` | Delete a secret. |
| [`list`](#list) | `list()` | `list_secrets()` | List secret names. Kubernetes and Local only. |
| | | [`create_secret(name, value)`](#create_secret) | Create a secret only if it doesn't exist. |
| | | [`secret_presence(name)`](#secret_presence) | Check a secret without reading its value. |
Secret names are passed to the backing service as they are, apart from the platform rewrites in [Behavior and limits](/docs/infrastructure/vault/behavior#names). Values are strings.
## get [#get]
Reads the current value of a secret. Throws `VAULT_SECRET_NOT_FOUND` when it doesn't exist.
```ts
const token = await credentials.get("github-token") // string
const github = await credentials.getJson<{ token: string }>("github") // parsed JSON
```
`getJson` throws a `SyntaxError` if the value isn't valid JSON.
```python
token = await credentials.get("github-token") # str
```
```rust
let token: String = credentials.get_secret("github-token").await?;
```
On GCP, `get` reads the latest version. On Azure, it reads the newest version.
## put [#put]
Creates the secret if it doesn't exist and overwrites it if it does.
```ts
await credentials.put("github-token", "ghp_example")
await credentials.putJson("github", { token: "ghp_example", scopes: ["repo"] }) // JSON.stringify
```
```python
await credentials.put("github-token", "ghp_example")
```
```rust
credentials.set_secret("github-token", "ghp_example").await?;
```
How each platform stores an overwrite:
| Platform | Overwrite |
| ---------- | ----------------------------------------------------------------- |
| AWS | A new parameter version (`Overwrite: true`). |
| GCP | A new secret version. Older versions stay enabled and are billed. |
| Azure | A new secret version. |
| Kubernetes | The Secret is replaced. |
| Local | The file is rewritten. |
## delete [#delete]
Deletes a secret. Deleting a secret that doesn't exist succeeds.
```ts
await credentials.delete("github-token")
```
```python
await credentials.delete("github-token")
```
```rust
credentials.delete_secret("github-token").await?;
```
On GCP, `delete` removes the secret and all its versions.
On Azure, the secret goes into Key Vault's soft-deleted state and keeps its name. A `put` with the same name fails until the retention period ends. See [Behavior and limits](/docs/infrastructure/vault/behavior#azure).
## list [#list]
Returns the names of the secrets in this vault, without the vault's prefix, so each name can be passed back to `get`.
```ts
const names: string[] = await credentials.list()
```
```python
names: list[str] = await credentials.list()
```
```rust
let names: Vec = credentials.list_secrets().await?;
```
| Platform | Supported |
| --------------- | ------------------------------------------------------------------------ |
| Kubernetes | Yes. Returns the stored names, which are lowercased and use `-` for `_`. |
| Local | Yes. |
| AWS, GCP, Azure | No. Fails with `OPERATION_NOT_SUPPORTED`. |
Why not on the clouds:
* **AWS:** parameter names are flat (`-`), so a prefix search for vault `app` would also return secrets from a vault named `app-prod`. Listing is disabled rather than mixing vaults.
* **GCP and Azure:** the client doesn't implement listing yet.
## create_secret [#create_secret]
Rust only. Creates a secret only if no secret with that name exists. If one does, the write is skipped and the call fails with `VAULT_SECRET_ALREADY_EXISTS`.
```rust
use alien_sdk::ErrorData;
match credentials.create_secret("signing-key", &generated).await {
Ok(()) => {}
Err(e) if matches!(e.error, Some(ErrorData::VaultSecretAlreadyExists { .. })) => {}
Err(e) => return Err(e.into()),
}
```
| Platform | How it's conditional |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | `PutParameter` with `Overwrite: false`. Atomic. |
| Azure | Lists the secret's versions, then writes if there are none. Not atomic, so use it only when one writer owns the secret. A soft-deleted secret counts as existing. |
| Kubernetes | Creates the Secret, which the API server refuses if it exists. Atomic. |
| Local | Checks the file, then writes. |
| GCP | Not supported. Fails with `OPERATION_NOT_SUPPORTED`. |
## secret_presence [#secret_presence]
Rust only. Reports whether a secret exists and is usable, from metadata only. The value is never fetched.
```rust
use alien_bindings::SecretPresence; // not re-exported from alien-sdk
match credentials.secret_presence("github-token").await? {
SecretPresence::Present { version } => { /* version changes on every overwrite */ }
SecretPresence::Missing => { /* never written */ }
SecretPresence::Invalid { reason } => { /* exists but can't be used as is */ }
}
```
`version` is the store's own identifier for the current value: an SSM parameter version, a Secret Manager version name, a Key Vault version ID, a Kubernetes `resourceVersion`, or a local write counter.
Compare it to decide when to restart a workload to pick up a new value.
`Invalid` covers cases such as an SSM parameter that isn't a `SecureString`, a disabled or destroyed latest version on GCP, or a disabled or expired version on Azure.
## The binding [#the-binding]
A linked Vault injects `ALIEN__BINDING`, uppercased with hyphens as underscores: `credentials` becomes `ALIEN_CREDENTIALS_BINDING`. The SDKs read it for you. It's JSON tagged by `service`:
| `service` | Platform | Fields |
| ------------------- | ---------- | -------------------------- |
| `parameter-store` | AWS | `vaultPrefix` |
| `secret-manager` | GCP | `vaultPrefix` |
| `key-vault` | Azure | `vaultName` |
| `kubernetes-secret` | Kubernetes | `namespace`, `vaultPrefix` |
| `local-vault` | Local | `vaultName`, `dataDir` |
The binding holds no secret values and no credentials. The workload's own identity authorizes each call.
## Errors [#errors]
| Code | Retryable | When |
| ----------------------------- | --------- | --------------------------------------------------------------------------------------------------------- |
| `VAULT_SECRET_NOT_FOUND` | No | `get` on a secret that doesn't exist. |
| `VAULT_SECRET_ALREADY_EXISTS` | No | `create_secret` on a secret that exists. |
| `OPERATION_NOT_SUPPORTED` | No | `list` on AWS, GCP, or Azure, or `create_secret` on GCP. |
| `CLOUD_PLATFORM_ERROR` | Yes | Any other failure from the secret service, including access denied and throttling. The cause is attached. |
| `BINDING_NOT_CONFIGURED` | No | The workload isn't linked to this resource. |
Errors name the secret, never its value.
# Vault behavior and limits (/docs/infrastructure/vault/behavior)
A [Vault](/docs/infrastructure/vault) keeps secret values encrypted in the customer's environment, readable only by the workloads you grant access. Its naming rules and limits come from each platform's secret service.
## Guarantees [#guarantees]
**Encrypted at rest on the clouds.**
* AWS encrypts `SecureString` parameters with the account's AWS managed key for SSM (`aws/ssm`).
* GCP and Azure encrypt with Google-managed and Microsoft-managed keys.
* Vault doesn't accept a customer-managed [Key](/docs/infrastructure/key).
**Encrypted in transit.** Every call to a cloud secret service uses TLS.
**Overwrite on put.** `put` creates the secret or overwrites it. Use `create_secret` (Rust) when you must not overwrite.
**Missing is an error.** `get` on a secret that doesn't exist throws `VAULT_SECRET_NOT_FOUND`, so you can tell an unwritten secret from an outage.
**Values never leave the environment through Alien.** The binding holds no values: your workload's identity reads them straight from the secret service. Alien's management identity can't read them unless you grant `vault/data-read` to management.
## Limits [#limits]
| Limit | AWS | GCP | Azure | Kubernetes | Local |
| --------------- | -------------------------------------------------------- | --------------------------------------------- | -------------------------------------- | ------------------------------------------ | -------- |
| Value size | 4 KB (Standard tier) | 64 KiB | 25 KB | About 1 MiB | No limit |
| Name characters | Letters, digits, `- _ . /` | Letters, digits, `- _` | Letters, digits, `-` (`_` becomes `-`) | Lowercase letters, digits, `-` (rewritten) | Any |
| `list` | No | No | No | Yes | Yes |
| Read throttling | 40 requests per second per account and region by default | 90,000 access requests per minute per project | 4,000 GETs per 10 seconds per vault | API server limits | None |
If your software deploys to more than one platform, keep values under 4 KB and names to lowercase letters, digits, and `-`. Alien doesn't check sizes before calling the service: an oversized value fails with `CLOUD_PLATFORM_ERROR`.
On AWS, parameters are written in the account's default tier, which is Standard unless the customer changed it:
* Standard parameters are capped at 4 KB, and an account can have 10,000 of them per region.
* Higher read throughput, up to 10,000 requests per second, is an account setting the customer can turn on, at extra cost.
## Names [#names]
Secret names are stored with a vault prefix, except on Azure, where each Vault resource has its own Key Vault:
| Platform | Stored as |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | Parameter `-` |
| GCP | Secret `-` |
| Azure | Secret `` with `_` replaced by `-` in the Vault's own Key Vault |
| Kubernetes | Secret `-`, lowercased, `_` replaced by `-`, other characters dropped, cut to 253 characters. The value is under the `value` key. |
| Local | Key `` in `secrets.json` |
`` is the stack's resource prefix, a hyphen, and the Vault ID.
Because of the rewrites, `API_KEY` and `api-key` are the same secret on Kubernetes, and `API_KEY` and `API-KEY` are the same secret on Azure.
## Access scoping [#access-scoping]
Permissions are granted per Vault resource:
* **AWS:** IAM policies on `parameter/-*`.
* **GCP:** Secret Manager roles with an IAM condition on the `-` name prefix. The deployer's own `deployer-secrets` vault is always excluded.
* **Azure:** Azure RBAC role assignments on the Vault's Key Vault.
* **Kubernetes:** the deployment's namespace. Vault permission sets have no Kubernetes mapping; access follows the cluster's RBAC for Secrets in that namespace.
On AWS and GCP the scope is a name prefix. If one Vault ID is another's ID plus a hyphen and more, such as `app` and `app-prod`, a grant on `app` also covers `app-prod`'s secrets.
Pick Vault IDs where none starts with another followed by `-`.
## Lifecycle [#lifecycle]
**Creating.** On AWS, GCP, and Kubernetes, creating a Vault creates nothing in the cloud: the vault is a name prefix, and each secret is created on its first `put`. On Azure, Alien creates a Key Vault.
**Deleting the resource.**
| Platform | What happens to the secrets |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| AWS | They stay in Parameter Store. Delete them yourself if you no longer need them. |
| GCP | They stay in Secret Manager and keep being billed. Delete them yourself. |
| Azure | The Key Vault is deleted with its secrets. It stays soft-deleted for the retention period, and purge protection keeps anyone from purging it early. |
| Kubernetes | They stay until the namespace is deleted, for example when the chart is uninstalled. |
| Local | The vault directory is removed. |
## Platform notes [#platform-notes]
### AWS [#aws]
SSM Parameter Store exists in every account and region, so there is nothing to provision.
Each `put` writes a `SecureString` parameter and adds a parameter version. Parameter Store keeps the last 100 versions of a parameter.
### GCP [#gcp]
Secrets use automatic replication. Each `put` adds a new version and leaves older versions enabled, and Secret Manager bills every enabled version.
A secret you overwrite often accumulates billed versions. If that matters, disable or destroy old versions with the customer's tools, or delete and recreate the secret.
### Azure [#azure]
Each Vault resource gets its own Key Vault:
* Standard SKU, Azure RBAC authorization.
* Soft delete and purge protection on, public network access on.
* Soft-delete retention is 90 days when the setup template creates the Key Vault (frozen) and 7 days when Alien creates it (live).
Deleted secrets are soft-deleted too, and keep their name for the retention period. A `put` to the name of a secret you deleted fails with `CLOUD_PLATFORM_ERROR` until the retention ends. Rotate by overwriting with `put`, not by deleting and recreating.
The Key Vault name is the resource prefix plus the Vault ID, at most 24 characters, which limits the Vault ID to 15 characters on Azure. Key Vault names are global and stay reserved while soft-deleted.
### Kubernetes [#kubernetes]
Secrets are Kubernetes `Secret` objects in the deployment's namespace, labeled `managed-by=operator` and `vault-prefix=`.
Encryption at rest depends on the cluster's etcd encryption settings, which the customer controls. Anyone with `get` on Secrets in the namespace can read them.
### Machines [#machines]
Alien doesn't create a vault on Machines. The customer provides one through an external binding, and the Vault must be `"frozen"`.
### Local [#local]
Secrets are stored as plain JSON in `vault//secrets.json` in the local state directory, with no encryption. Use Local for development only.
# Vault (/docs/infrastructure/vault)
A Vault is a store for the secrets your software creates or changes while it runs, such as tokens from an integration or keys it rotates, in the cloud's own secret service. The values stay in the customer's environment, encrypted by the cloud provider, and the customer can audit access with their own tools.
```ts title="alien.ts"
import * as alien from "@alienplatform/core"
const credentials = new alien.Vault("credentials").build()
const api = new alien.Worker("api")
.code({ type: "source", src: "./api", toolchain: { type: "typescript" } })
.link(credentials)
.permissions("execution")
.build()
export default new alien.Stack("acme")
.add(credentials, "frozen")
.add(api, "live")
.permissions({
profiles: { execution: { credentials: ["vault/data-read", "vault/data-write"] } },
})
.build()
```
In this stack:
* `.link(credentials)` connects the vault to the `api` Worker, the **workload** (the running code) that uses it. Alien injects the vault's connection details, its **binding**, into the Worker, read by the SDK client.
* The `execution` [permission profile](/docs/guide/permissions), the named set of cloud permissions a workload runs with, grants `vault/data-read` and `vault/data-write`.
* `"frozen"` means the customer's admin creates the vault during initial setup. `"live"` resources, like the Worker, are created and updated by Alien on every release. See [Frozen and live](/docs/guide/frozen-and-live).
The `api` Worker writes and reads secrets at runtime:
```ts title="api/src/credentials.ts"
import { vault } from "@alienplatform/sdk"
const credentials = vault("credentials")
await credentials.putJson("github", { token: "ghp_example" })
const github = await credentials.getJson<{ token: string }>("github")
```
## Platforms [#platforms]
`credentials` becomes secrets in the environment's native secret service:
| Platform | Backing service | Created by |
| ---------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| AWS | SSM Parameter Store `SecureString` parameters named `-` | Nothing to create. The vault is a name prefix. |
| GCP | Secret Manager secrets named `-`, automatic replication | Nothing to create. The vault is a name prefix. |
| Azure | One Key Vault per Vault resource, Standard SKU, Azure RBAC | Alien |
| Kubernetes | Kubernetes Secrets in the deployment's namespace | Nothing to create. Secrets are created on first write. |
| Machines | A vault the customer provides | The customer, through an external binding. The resource must be `"frozen"`. See [Machines](/docs/infrastructure/platforms/machines). |
| Local | `vault//secrets.json` in the local state directory, not encrypted | Alien |
`` is the stack's resource prefix followed by the Vault's ID.
## Frozen or live [#frozen-or-live]
Vault can be `"frozen"` (created once during initial setup by the customer's admin) or `"live"` (created and updated by Alien on releases). See [Frozen and live](/docs/guide/frozen-and-live).
The ID may contain letters, digits, `-`, and `_`. On Azure it becomes part of a Key Vault name, which limits the ID to 15 characters.
Alien reserves two IDs for its own vaults. Don't declare a Vault with either:
* `secrets` holds environment secrets.
* `deployer-secrets` holds secrets the deployer (the person who runs the initial setup, usually the customer's admin) writes with their own credentials.
## Alternatives [#alternatives]
Vault is for secrets your code writes at runtime. For secrets the deployer enters during initial setup, or that you set per deployment, use [stack inputs and environment secrets](/docs/guide/inputs-and-secrets):
| | Inputs and environment secrets | Vault |
| ------------------- | ---------------------------------------------------- | -------------------- |
| Set by | The deployer at initial setup, or you per deployment | Your code at runtime |
| Read by | Environment variables | SDK calls |
| Changes take effect | On the next deployment | On the next `get` |
Use [KV](/docs/infrastructure/kv) for non-secret state, and [Key](/docs/infrastructure/key) to encrypt data with a customer-managed key.
## Options [#options]
| Method | Type | Default | Notes |
| ----------------- | ------------------- | --------- | --------------------------------------------------- |
| `.enabled(input)` | boolean stack input | always on | Creates the resource only when the input is `true`. |
The backing service follows the platform. There are no other options.
## Use it from code [#use-it-from-code]
```ts title="src/credentials.ts"
import { vault } from "@alienplatform/sdk"
const credentials = vault("credentials")
await credentials.putJson("github", { token: "ghp_example" })
const github = await credentials.getJson<{ token: string }>("github")
await credentials.delete("github")
```
```python title="credentials.py"
from alienplatform import vault
credentials = vault("credentials")
await credentials.put("github-token", "ghp_example")
token = await credentials.get("github-token")
await credentials.delete("github-token")
```
```rust title="src/credentials.rs"
use alien_sdk::Bindings;
let bindings = Bindings::from_env()?;
let credentials = bindings.vault("credentials").await?;
credentials.set_secret("github-token", "ghp_example").await?;
let token = credentials.get_secret("github-token").await?;
credentials.delete_secret("github-token").await?;
```
Every method is in the [API reference](/docs/infrastructure/vault/api).
## Common patterns [#common-patterns]
### Name secrets so they work everywhere [#name-secrets-so-they-work-everywhere]
Use lowercase letters, digits, and hyphens, such as `github-token`. Azure turns `_` into `-` and Kubernetes lowercases names, so `API_KEY` and `api-key` can end up as the same secret.
Keep values under 4 KB, the smallest limit across platforms. See [Behavior and limits](/docs/infrastructure/vault/behavior#limits).
### Store structured secrets [#store-structured-secrets]
`putJson` and `getJson` (TypeScript) serialize to and from a JSON string, so you can keep related values, such as a client ID and secret, under one name. In Python and Rust, serialize with `json` or `serde_json`.
### Cache reads [#cache-reads]
Every `get` is a call to the cloud's secret service, and some services throttle reads. Read a secret once at startup, or cache it for a few minutes, instead of reading it on every request.
### Keep a list of names yourself [#keep-a-list-of-names-yourself]
`list()` works only on Kubernetes and Local. On AWS, GCP, and Azure it fails with `OPERATION_NOT_SUPPORTED`.
To enumerate secrets, store the names you've written in a [KV](/docs/infrastructure/kv) entry or a separate secret.
## Management access [#management-access]
By default, only workloads you grant can read or write a Vault you declare. Alien's management identity, which runs [remote bindings](/docs/guide/remote-bindings) for your hosted software, can't.
To let your hosted software write secrets into a customer's vault, for example after an OAuth flow, extend management permissions:
```ts title="alien.ts"
export default new alien.Stack("acme")
.add(credentials, "frozen")
.permissions({
profiles: { execution: { credentials: ["vault/data-read"] } },
management: { extend: { credentials: ["vault/data-write"] } },
})
.build()
```
`vault/data-write` creates, updates, and deletes secrets but can't read values back. Grant `vault/data-read` to management only if your hosted software must read secret values.
## Permissions [#permissions]
| Set | Grants |
| -------------------------------------------------------- | ----------------------------------------------------------------- |
| `vault/data-read` | Reading secret values. |
| `vault/data-write` | Creating, updating, and deleting secrets, without reading values. |
| `vault/certificate-management` | Managing application TLS certificates in the shared vault. |
| `vault/provision`, `vault/management`, `vault/heartbeat` | Used by initial setup and by Alien's management identity. |
Linking a Vault to a workload with no permission set named for it grants `vault/data-read` and `vault/data-write`. See [Permissions](/docs/guide/permissions).
## Next [#next]
* [API reference](/docs/infrastructure/vault/api)
* [Behavior and limits](/docs/infrastructure/vault/behavior)
* [Pricing](/docs/infrastructure/vault/pricing)
* [Inputs and secrets](/docs/guide/inputs-and-secrets)
# Vault pricing (/docs/infrastructure/vault/pricing)
A Vault costs from nothing on AWS to about $10 a month for a million reads on GCP, paid by the customer on their own cloud bill. Alien adds no per-secret charge. For Alien's own pricing, see [Pricing](/pricing).
Prices are the providers' list prices as of October 2026 in AWS us-east-1, GCP us-central1, and Azure East US. Official pricing is linked under [Sources](#sources).
## How each service bills [#how-each-service-bills]
| Provider | Service | Secrets | API calls |
| ---------- | ---------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| AWS | SSM Parameter Store, Standard tier | Free, up to 10,000 parameters per region | Free at standard throughput. $0.05 per 10,000 calls if the account turns on higher throughput. |
| GCP | Secret Manager | $0.06 per enabled secret version per month | $0.03 per 10,000 access operations |
| Azure | Key Vault Standard | No charge per secret | $0.03 per 10,000 operations |
| Kubernetes | Kubernetes Secrets | No charge | No charge |
Azure creates one Key Vault per Vault resource. Key Vault has no base charge, so an idle vault costs nothing.
On GCP, every `put` adds a version, and old versions stay enabled and billed. Overwriting one secret every day adds about 30 billed versions a month.
## Example [#example]
20 secrets, each overwritten 5 times, and 1 million reads in a month:
| Provider | Secrets | Reads | Total |
| ------------------ | ---------------------------------------------------- | ----------------------------------------- | --------------- |
| AWS SSM | Free | Free | **$0** |
| GCP Secret Manager | 20 × 6 versions = 120 − 6 free = 114 × $0.06 = $6.84 | 1,000,000 − 10,000 free = 990,000 → $2.97 | **about $9.81** |
| Azure Key Vault | Free | 1,000,000 + 100 writes → $3.00 | **about $3.00** |
Caching values in your process cuts the read cost on GCP and Azure, and keeps you under AWS's standard throughput limit.
## Free tiers [#free-tiers]
* **AWS:** Standard parameters at standard throughput are always free.
* **GCP:** 6 enabled secret versions and 10,000 access operations per month.
* **Azure:** none for Key Vault operations.
## Sources [#sources]
* [AWS Systems Manager pricing](https://aws.amazon.com/systems-manager/pricing/)
* [Secret Manager pricing](https://cloud.google.com/secret-manager/pricing)
* [Azure Key Vault pricing](https://azure.microsoft.com/en-us/pricing/details/key-vault/)
# Worker API reference (/docs/infrastructure/worker/api)
Worker code talks to the Alien worker runtime that runs next to it. You register handlers; the runtime delivers HTTP requests, commands, and trigger events to them.
| Language | Package | Handlers available |
| ---------- | ------------------------------------------------- | -------------------------------------------------------------------------------- |
| TypeScript | `@alienplatform/sdk` | HTTP, commands, queue, queue batch, storage, cron, `waitUntil` |
| Python | `alienplatform` (install `alienplatform[worker]`) | Queue, queue batch. Worker binding. |
| Rust | `alien-sdk` (`AlienContext`) | HTTP, commands, queue, queue batch, storage, cron, `wait_until`. Worker binding. |
## TypeScript [#typescript]
### HTTP [#http]
Export an HTTP app as the module's default export. Any framework with a `fetch` handler works, such as Hono. The runtime serves it on loopback and forwards the Worker's public endpoint traffic to it.
```ts
import { Hono } from "hono"
const app = new Hono()
app.get("/health", c => c.json({ ok: true }))
export default app
```
### command [#command]
```ts
function command(
name: string,
handler: (params: unknown, context: WorkerCommandContext) => TResult | Promise,
): void
function command(
name: string,
schema: Schema,
handler: (
params: StandardSchemaOutput,
context: WorkerCommandContext,
) => TResult | Promise,
): void
interface WorkerCommandContext {
commandId: string
attempt: number // starts at 1; above 1 means redelivery
deadline?: Date
}
```
Registers a command handler. The schema form validates params with any Standard Schema v1 validator, such as zod or valibot.
Invalid params throw `Command input failed validation: `. An unknown command name fails with the list of registered names.
Delivery is at least once: use `attempt` to make redeliveries safe. Requires `.commandsEnabled(true)`. See [Commands](/docs/guide/commands).
### onQueueMessage [#onqueuemessage]
```ts
function onQueueMessage(
queueName: string,
handler: (message: QueueMessageEvent) => Promise,
): () => void
interface QueueMessageEvent {
id: string
source: string // queue name
receiptHandle: string
payload: T
attemptCount: number
timestamp: Date
attributes?: { [key: string]: string }
}
```
Called once per message. Resolving acknowledges the message. Throwing makes it available for redelivery.
### onQueueBatch [#onqueuebatch]
```ts
function onQueueBatch(
queueName: string,
handler: (batch: QueueBatchEvent) => Promise,
): () => void
interface QueueBatchEvent {
source: string
deliveries: QueueDelivery[]
}
interface QueueDelivery {
deliveryId: string // identifies this delivery attempt, not the message
message: QueueMessageEvent
}
interface QueueDeliveryOutcome {
deliveryId: string
disposition: "success" | "retry"
}
```
Needs a queue trigger with `batch`. Return one outcome per delivery. A `retry` outcome redelivers that message only; it doesn't roll back the others.
Batches can be smaller than `maxMessages` and are unordered.
### onStorageEvent [#onstorageevent]
```ts
function onStorageEvent(
bucket: string,
handler: (event: StorageEvent) => Promise,
options?: { prefix?: string },
): () => void
interface StorageEvent {
bucketName: string
objectKey: string
eventType:
| "created"
| "deleted"
| "copied"
| "metadataUpdated"
| "restored"
| "tierChanged"
| "unknown"
timestamp: string
size?: number
etag?: string
contentType?: string
versionId?: string
region?: string
metadata?: Record
copySource?: string
currentTier?: string
previousTier?: string
}
```
`prefix` filters by object key.
### onCronEvent [#oncronevent]
```ts
function onCronEvent(
scheduleName: string,
handler: (event: CronEvent) => Promise,
): () => void
interface CronEvent {
scheduleName: string
timestamp: string
}
```
### waitUntil [#waituntil]
```ts
function waitUntil(promise: Promise