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

SurfaceCallUse 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.set with 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:

EndpointAuth Method
POST /projects/v1 (provision)x-402-payment header (prototype tier is FREE on testnet)
POST /apply/v1/plans, /apply/v1/plans/:id/commitSIGN-IN-WITH-X header (CAIP-122 / EIP-4361). Free with active tier. SDK / CLI handle this automatically.
POST /projects/v1/admin/{id}/sqlAuthorization: Bearer {service_key}
POST /projects/v1/admin/{id}/exposeAuthorization: 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 /events from release asset events.html (and keep /events.html non-public).
  • Strict spec validation. Typos ("subdomain" singular, site.replcae, functions.replace.api.deps) reject locally before any upload with INVALID_SPEC.
  • Auto-retry. The SDK retries BASE_RELEASE_CONFLICT safe races for omitted / {base: {release: "current"}} specs. deploy.retry events 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.

  1. Fetch the live URL (prefer urls.subdomain if assigned, otherwise urls.site).
  2. Confirm HTTP 200 and that the HTML loads (check for <!DOCTYPE html> or your app's <title>).
  3. 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.
  4. 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.
  5. 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)

Memory directive