Step 15: Deploy to run402
Phase: deploy
Context
You have verified app_files and project_id. Time to put the app online.
What to do
Deploys go through run402's unified deploy primitive: one declarative call that activates the site, assigns the subdomain, applies any DB migrations, sets RLS, and deploys functions — atomically. Free with an active tier.
The HTTP wire is two-step CAS (POST /apply/v1/plans → upload missing bytes → POST /apply/v1/plans/:id/commit); don't hand-roll it. Pick one of these:
Recommended: SDK, CLI, or MCP
| Surface | Call | Use when |
|---|---|---|
| SDK (TypeScript / Node) | (await r.project(id)).apply(spec) from @run402/sdk@2.46.0 or newer |
You're authoring code. Typed, retries safe races automatically. |
| CLI | run402 deploy apply --manifest app.json |
Scripted / shell agent. JSON in, JSON out. |
| MCP tool | The Run402 apply/deploy tool that accepts a ReleaseSpec |
You're in Claude Code / Cursor with run402-mcp. |
If run402-mcp is available, use its apply/deploy tool with a ReleaseSpec. Subdomain assignment belongs in subdomains.set, not a separate call.
SDK: Use const p = await r.project(id), then await p.apply(spec). Put static files, functions, database migrations, expose manifests, secrets, routes, and subdomains.set in the same ReleaseSpec. Use r.assets for asset storage.
The ReleaseSpec — one shape for everything
The spec is declarative: each resource group uses replace (whole desired state) or patch (surgical update). Minimum site deploy with subdomain:
{
"site": {
"replace": {
"index.html": "<!doctype html>...",
"style.css": "body { ... }",
"app.js": "const CONFIG = ...",
"logo.png": { "data": "<base64>", "encoding": "base64" }
}
},
"subdomains": { "set": ["myapp"] }
}
File entries accept a bare UTF-8 string, { "data": "...", "encoding": "utf-8" | "base64", "contentType": "..." }, or { "path": "dist/index.html" } (CLI/SDK normalize relative paths against the manifest file or cwd). The SDK / CLI hash each file, ask the gateway which bytes it doesn't already have, and only upload those.
Full bundle (DB + RLS + functions + secrets + site + subdomain)
{
"database": {
"migrations": [{ "id": "001_init", "sql": "CREATE TABLE ..." }],
"expose": {
"version": "1",
"tables": [
{ "name": "todos", "expose": true, "policy": "user_owns_rows", "owner_column": "user_id" }
]
}
},
"secrets": { "require": ["STRIPE_KEY"] },
"functions": {
"replace": {
"api": { "source": "export default async (req) => Response.json({ ok: true })" }
}
},
"site": { "replace": { "index.html": "..." } },
"subdomains": { "set": ["myapp"] }
}
Secrets values do not go in the spec — declare keys with secrets.require[], set values out-of-band first via the secrets API (p.secrets.set(key, value) on the scoped client, or POST /projects/v1/admin/{id}/secrets). The commit phase hard-errors if a required key is missing.
SDK example (Node, @run402/sdk 2.46.0+)
import { run402, Run402DeployError } from "@run402/sdk/node";
const r = run402();
const p = await r.project(env.PROJECT_ID); // async — scope to the project
try {
const result = await p.apply({
site: { replace: fileSet }, // { "index.html": "...", ... }
subdomains: { set: ["myapp"] }, // optional — inline subdomain assignment
});
console.log(result.release_id, result.urls);
// result.urls.site, result.urls.subdomain
} catch (err) {
if (err instanceof Run402DeployError) {
// err.code: BASE_RELEASE_CONFLICT | INVALID_SPEC | MIGRATION_FAILED | ...
// err.nextActions: structured advisory
}
throw err;
}
For live progress events: const op = await p.apply.start(spec); for await (const ev of op.events()) { ... }. To resume an interrupted operation: await p.apply.resume(operationId). Release observability lives on p.deploy.{getRelease, getActiveRelease, diff, resolve, list, events}.
Subdomain assignment is inline
No separate /subdomains/v1 call. The deploy state machine atomically assigns (or reassigns on redeploy) the subdomain as part of activation. Use subdomains.set: ["name"] in the same spec.
Derive the subdomain automatically: take the app name, lowercase it, replace spaces and underscores with hyphens, strip non-alphanumeric characters (except hyphens), collapse consecutive hyphens, and truncate to 63 characters. Only skip the subdomain if the user explicitly declines.
Subdomain rules
- 3-63 characters, lowercase letters, numbers, and hyphens only
- Must start and end with a letter or number (no leading/trailing hyphens)
- No consecutive hyphens (
--) - Reserved words blocked: api, www, admin, app, dashboard, docs, help, support, cdn, static, dev, staging, test, demo, run402, others
- Each subdomain is owned by the project that assigned it — other projects cannot take it
- Subdomain assignment/reassignment is free with an active tier
If subdomain assignment fails
The deploy spec is rejected by the planning phase before any bytes upload. Possible reasons:
- 409 Conflict — the name is already taken (or reserved for a previous owner whose lease is still in grace). Suggest a different name.
- 400 Bad Request — name violates the rules above.
- SUBDOMAIN_MULTI_NOT_SUPPORTED — only one subdomain per project. Use
subdomains.setwith exactly one entry.
Fallback: If subdomain assignment fails, retry the deploy without the subdomains field. The site still deploys; the user gets the raw deployment URL (result.urls.site). Don't retry in a loop.
Auth model reference
Different run402 endpoints use different authentication methods:
| Endpoint | Auth Method |
|---|---|
POST /projects/v1 (provision) | x-402-payment header (prototype tier is FREE on testnet) |
POST /apply/v1/plans, /apply/v1/plans/:id/commit | SIGN-IN-WITH-X header (CAIP-122 / EIP-4361). Free with active tier. SDK / CLI handle this automatically. |
POST /projects/v1/admin/{id}/sql | Authorization: Bearer {service_key} |
POST /projects/v1/admin/{id}/expose | Authorization: Bearer {service_key} |
GET /rest/v1/... | apikey header (anon_key or service_key) |
Important notes
- No 50 MB body cap. Bytes go through CAS; the SDK uploads the manifest itself to CAS when it exceeds 5 MB.
- Immutable releases. Each deploy produces a new
release_id. The active release is what the subdomain serves. - Skip-unchanged. Re-deploying an unchanged tree returns immediately with no Lambda churn — function code, static bytes, and migrations are all deduped by hash.
- Clean URLs. Add
site.public_paths: { mode: "explicit", replace: { "/events": { "asset": "events.html", "cache_class": "html" } } }to serve/eventsfrom release assetevents.html(and keep/events.htmlnon-public). - Strict spec validation. Typos (
"subdomain"singular,site.replcae,functions.replace.api.deps) reject locally before any upload withINVALID_SPEC. - Auto-retry. The SDK retries
BASE_RELEASE_CONFLICTsafe races for omitted /{base: {release: "current"}}specs.deploy.retryevents stream during recovery.
Verify deployment
The apply call resolves only when the state machine reaches ready (or fails). On success, you have result.release_id and result.urls. To inspect after the fact:
const p = await r.project(projectId);
const active = await p.deploy.getActiveRelease();
const events = await p.deploy.events(operationId);
Smoke-test gate
After deploy resolves, you MUST verify the app is actually live before proceeding.
- Fetch the live URL (prefer
urls.subdomainif assigned, otherwiseurls.site). - Confirm HTTP 200 and that the HTML loads (check for
<!DOCTYPE html>or your app's<title>). - If the fetch fails or returns non-200, wait 5 seconds and retry once. CDN propagation is usually instant but can take a few seconds.
- If it still fails, tell the user: "The deploy succeeded but the site isn't responding yet. Let's give it a moment." Retry up to 3 times total.
- Do NOT proceed to Step 16 until the smoke test passes.
Expected output
release_id— Stable id for this release (e.g.rel_...)deployment_url— Raw deployment URL (e.g.https://dpl-xxx.run402.com)subdomain— The assigned subdomain name (if any)subdomain_url— The memorable URL (e.g.https://myapp.run402.com)