Skip to content

IaC Reference (stack.json)

stack.json declares everything an environment runs. Keep it in git and apply it with kernstack deploy.

The envelope

Key Contents
stack Identity: target, space pin, deploy gate
hooks CLI-only shell commands run during deploy
apps CLI-only frontend builds published to buckets
gateway routes[] with their domains and API keys
resources functions, workers, buckets, tables, search_indexes, inference_endpoints, secrets, alert_rules, alert_channels (all optional)

Starter file from kernstack init:

{
  "stack": { "target": "my-app.default" },
  "gateway": { "routes": [] },
  "resources": { "functions": [], "workers": [], "buckets": [], "tables": [] }
}

Access keys are not stack resources. Reference the deploy key with {{ref-access-key-id-current}}, {{ref-access-key-secret-current}}, and {{ref-dzone-current}}, stack secrets with {{ref-secret-stack://name}}, and local values with {{ref-env-file://KEY}} (.stack.env).

Project layout

my-project/
├── stack.json
├── stack.<env>.json    # optional profile overlay
├── .stack.env          # gitignored deploy values (optional)
├── functions/<name>/   # function source
├── workers/<name>/     # worker source
├── apps/<name>/        # frontend source
├── seed/
│   ├── tables/<name>.json
│   └── buckets/<name>/
├── scripts/            # hook scripts
├── reference/<topic>/  # optional repo-only adjacent code (not deployed)
└── .kernstack/         # CLI-managed: config, SDKs, build cache (not in git)

Omit src when source is at functions/{name} or workers/{name}. Omit seed when seed data is at seed/tables/{name}.json / .jsonl or seed/buckets/{name}/. Seeds upload after every successful deploy.

Table seed items use typed values (S, N, B, BIN, L, M, NULL), max 100 items per file:

[{ "id": { "S": "user-1" }, "email": { "S": "ada@example.com" }, "visits": { "N": "3" } }]

Deploy flags --skip-seed, --seed-only, --seed-prefix, --skip-hooks, --skip-all-apps, and --skip-app control seeds, hooks, and app builds per run — see the CLI reference.

Repo-only code (reference/)

Put adjacent code that must live in the repo but is not deployed — embedded firmware, protocol definitions, vendor material, hardware notes — under reference/<topic>/ (free-form topics such as reference/drivers/ or reference/protocols/; there is no registry and no stack.json entry). It exists so humans and the collab agent can analyse cross-system behaviour (a shared CAN protocol, app-visible firmware behaviour) next to the deployed app. Do not put it under apps/ — that is a deploy concept.

The CLI treats reference/ as inert by contract: it is never discovered as a workload, built, packaged, fingerprinted, seeded, or validated, and adding or changing files there never changes a deploy fingerprint or triggers a redeploy. The collab agent reads it as context, not as a deploy target, and does not propose platform resources from it unprompted. reference/ is not a submodule or external-repo mechanism, and KernStack does not build or flash anything in it.

Code ships only by explicit promotion: move it into functions/, workers/, or apps/, or deliberately point a resource's src at it (for example "src": "reference/drivers/bms" or "src": "."). A src that covers reference/ opts that tree back into packaging and fingerprinting.

Keep it curated. Upstream vendor drops (kernels, CMSIS/HAL, vendor SDKs, toolchains) are re-fetched when needed, not committed. Projects scaffolded by kernstack init already ignore build artifacts under reference/ (object files, maps, build/ output); curated firmware images (*.bin/*.elf/*.hex) are not ignored, so commit them sparingly. Existing projects can add the same ignores:

reference/**/build/
reference/**/*.o
reference/**/*.obj
reference/**/*.a
reference/**/*.map
reference/**/*.lst
reference/**/*.d

Frontend apps (apps)

Each entry builds a static site and publishes it to a bucket. Guide: Frontend apps.

{
  "apps": [
    { "name": "web", "build": "npm ci --prefix apps/web && npm run build --prefix apps/web", "out": "apps/web/www", "seed": "seed/buckets/www" }
  ]
}
Field Required Default Meaning
name yes — App id
src no apps/{name} Source directory
build yes — Shell command, run from the project root
out yes — Build output directory
seed no seed/buckets/{name} Bucket seed directory replaced with out

On deploy, apps build after hooks.build and before packaging. After apply, the CLI writes kernstack-routes.json ({ "core": "<webserver URL>", "routes": { "<name>": "<url>" } }) into every prefix-route and app bucket — read it at runtime instead of baking URLs into the build.

Build env: KERNSTACK_PROJECT_ROOT, KERNSTACK_STACK_FILE, KERNSTACK_HOOK_PHASE=app_build, KERNSTACK_APP_NAME, KERNSTACK_APP_SRC, KERNSTACK_APP_OUT, KERNSTACK_APP_SEED.

Only declared entries build. Keep native mobile sources under apps/ without an entry. For SSR, run the app as a worker ("src": "apps/web").

Hooks

{ "hooks": { "build": "bash scripts/build.sh", "post_deploy": "bash scripts/post-deploy.sh" } }
Field Runs
build Before apps[] builds and packaging
post_deploy After apply and seed upload

Stack identity (stack)

Field Type Required Meaning
target string yes Deploy profile {project}.{env}; must match the active credentials
space_id string no Pin to one environment; deploy fails elsewhere
name string no Label for stack history
description string no
vars map no Stack variables
output map no Stack outputs
min_source_assurance string no Deploy gate (see below)
logs_search_max_gb integer no Size of the platform _logs-search index in GB (default 1)

Run kernstack use <env> to keep target, credentials, and .kernstack/config aligned. kernstack use qa writes stack.qa.json with the target and space pin.

min_source_assurance refuses deploys whose source is less trusted than: none (default), locally_authenticated, commit_backed, repository_verified, review_approved, policy_approved, attested_build. Override with kernstack deploy --allow-unverified (recorded).

Gateway

All exposure lives in gateway.routes[]. Public requests carry X-Forwarded-For, X-Forwarded-Proto, and IP-based X-Kernstack-Viewer-* location headers.

Route (gateway.routes[])

Field Type Required Meaning
name string yes Unique logical id (drift, {{ref-alias://name}})
type string no http (default), websocket (functions only), prefix (multi-file bucket site)
target object yes { "kind": "function\|worker\|bucket\|inference", "name": "..." }
private boolean no Require an API key
cdn_enabled boolean no Serve through the CDN
prefix string no Bucket: expose only this key prefix
key string no Bucket: single-object alias
default_key string no Bucket: object served for / (e.g. index.html)
canonical_redirects boolean no Bucket prefix routes: 301 alternate page URLs to their canonical form (default true) — canonical URLs
fallback_mode string no Bucket prefix routes: "spa" (default) serves default_key with 200 for unknown paths, "not_found" returns a hard 404 — not-found pages
block_alias_url boolean no Allow custom-domain traffic only
domains array no Custom hostnames
api_keys array no API keys

Bucket routes without "type": "prefix" are single-object aliases — use prefix + default_key for sites.

{
  "gateway": {
    "routes": [
      { "name": "api", "type": "http", "target": { "kind": "function", "name": "api" } },
      { "name": "live", "type": "websocket", "target": { "kind": "function", "name": "realtime" } },
      { "name": "www", "type": "prefix", "default_key": "index.html", "cdn_enabled": true, "target": { "kind": "bucket", "name": "www" } }
    ]
  }
}

Not-found pages

On a prefix bucket route, the gateway tries the exact key for /x first. If the request has no extension and default_key is HTML, it then tries x/index.html, then x.html. If none exists:

  1. errors/404.html exists under the route's prefix (or at the bucket root if no prefix is set): it is served with HTTP 404 for every missing path.
  2. Otherwise, in the default "spa" fallback mode, extensionless and .html paths get default_key with HTTP 200. This is the single-page-app fallback.
  3. Any other missing path (assets) returns a plain 404.

Static and SEO sites should set "fallback_mode": "not_found" so unknown URLs return a hard 404 instead of a "soft 404" (the homepage served with status 200) — with errors/404.html as the branded body when present, a plain 404 otherwise:

{ "name": "site", "type": "prefix", "default_key": "index.html", "fallback_mode": "not_found", "target": { "kind": "bucket", "name": "www" }, "domains": [{ "hostname": "example.com" }] }

Because the 404 page is served at the requested URL, write its asset links as root-absolute paths (/assets/site.css, including any path_prefix). Add <meta name="robots" content="noindex"> to it too. Single-page apps that rely on deep links keep the default "spa" mode and must not ship errors/404.html, because it turns the 200 fallback off.

Canonical URLs

On custom domains, every page on a prefix bucket route has one URL, set by the object that serves it. Other forms of that URL get a 301 to it, keeping the query string and path_prefix. Below, index.html stands for the route's HTML default_key.

Object served Canonical URL Redirected to it
x/index.html /x/ /x, /x/index.html
x.html /x /x/
Exact key x /x /x/
Root index.html / /index.html

Folder layouts (x/index.html) and flat layouts (x.html) both work without configuration, and relative links resolve correctly in each. If both exist, x/index.html wins. Exact keys are served as-is, so extensionless files such as .well-known/apple-app-site-association are never redirected. /x.html is also served as-is, so site-verification files keep working. Single-page-app fallback responses are never redirected.

Set "canonical_redirects": false if your framework handles these redirects itself. Alias URLs never redirect; keep <link rel="canonical"> in your pages to cover them.

{ "name": "site", "type": "prefix", "default_key": "index.html", "canonical_redirects": false, "target": { "kind": "bucket", "name": "www" }, "domains": [{ "hostname": "example.com" }] }

Domains (gateway.routes[].domains[])

Register and verify the apex in the console (Settings → Domains), then bind hostnames here. Deploy returns the CNAME target (dmns.kernstack.com).

Field Type Required
hostname string yes
path_prefix string no
  • Without path_prefix the whole hostname binds to the route.
  • With path_prefix (e.g. /api) only that segment-bounded path binds; the prefix is stripped before invoke and passed in X-Forwarded-Prefix.
  • A hostname that uses path prefixes needs exactly one route without one (catch-all). Max 10 prefixes per hostname.
  • Moving a hostname from another environment needs --allow-domain-takeover; removing one needs --confirm-destructive.
{
  "gateway": {
    "routes": [
      { "name": "web", "target": { "kind": "worker", "name": "frontend" }, "block_alias_url": true, "domains": [{ "hostname": "www.example.com" }] },
      { "name": "api", "target": { "kind": "function", "name": "api" }, "block_alias_url": true, "domains": [{ "hostname": "www.example.com", "path_prefix": "/api" }] }
    ]
  }
}

Redirects (gateway.redirects[])

A redirect answers every request on a hostname with an HTTP redirect, without routing it to any resource. A typical use is sending www to the apex.

Field Type Required Meaning
hostname string yes Bare hostname (no scheme, port or wildcard)
to string yes Absolute http(s) URL, e.g. https://example.com
status integer no 301 (default), 302, 307 or 308
preserve_path boolean no Append the request path and query to to (default true); false always redirects to to exactly
  • The hostname needs a verified certificate, the same as domains. An apex verified in the console covers www.<apex>. Point it at the CNAME target that deploy prints (dmns.kernstack.com).
  • A hostname is either a redirect or a route domain, never both.
  • With preserve_path, to must not carry a query string.
  • Removing a redirect releases the hostname and needs no --confirm-destructive. Taking a hostname from another environment needs --allow-domain-takeover.
{
  "gateway": {
    "routes": [
      { "name": "site", "type": "prefix", "default_key": "index.html", "target": { "kind": "bucket", "name": "www" }, "domains": [{ "hostname": "example.com" }] }
    ],
    "redirects": [
      { "hostname": "www.example.com", "to": "https://example.com" }
    ]
  }
}

API keys (gateway.routes[].api_keys[])

Field Type Required Meaning
name string yes Key name
expires_at string no RFC 3339 expiry
rotate boolean no Mint a new key on the next deploy
from_route string no Reuse the same-named key of another route (e.g. a private page calling its private API); set rotate / expires_at on the source

The raw key is printed once, on first deploy. Reference a key ID with {{ref-api-key://route-name/key-name}}.

Tables (resources.tables[])

{ "resources": { "tables": [{ "name": "items", "key_name": "id", "key_type": "S", "indexes": [] }] } }
Field Type Required
name string yes
key_name / key_type string yes
sortkey_name / sortkey_type string no
indexes array yes ([] for none)
change_stream object no
seed string no (CLI-only file path)

Key types: S / string, N / number, BIN / binary, B / bool. Each index has name, key_name, key_type, and optional sortkey_name / sortkey_type.

Change stream (resources.tables[].change_stream)

Field Type Required Meaning
function string yes Function in resources.functions[] invoked with batched item changes
  • The target must be a function of the same stack; workers are rejected.
  • Delivers changes written after the stream is first enabled. Redeploying an unchanged stream keeps its position.
  • Removing change_stream disables the stream on the next deploy; adding it back starts fresh from that deploy. Changing function repoints it and continues from the current position.
  • Configuration changes reach delivery within about 10 seconds of deploy. See change streams for delivery guarantees and the event format.
{
  "resources": {
    "tables": [{ "name": "orders", "key_name": "id", "key_type": "S", "indexes": [], "change_stream": { "function": "on-order" } }],
    "functions": [{ "name": "on-order" }]
  }
}

Buckets (resources.buckets[])

{ "resources": { "buckets": [{ "name": "uploads" }] } }
Field Type Required
name string yes
retention_policies array no
volumes array no
seed / seed_prefix string no (CLI-only directory and key prefix)

Retention policy: prefix, retention_seconds, include_subfolders (required), optional notify_function.

Volumes (resources.buckets[].volumes[])

A volume is a frozen snapshot of a bucket prefix that workers can mount.

Field Type Required Meaning
name string yes Unique across the stack
source_prefix string no Bucket prefix to snapshot
read_only boolean no Default true; false forces a single worker instance
{
  "resources": {
    "buckets": [{ "name": "assets", "volumes": [{ "name": "model-data", "source_prefix": "models/" }] }],
    "workers": [{ "name": "inference", "volumes": [{ "name": "model-data" }] }]
  }
}

Functions (resources.functions[])

Field Type Meaning
name string Required
src string Source dir (default functions/{name})
env_vars map Environment variables
schedules array Cron triggers (below)
store_source_bundle boolean Store raw source (default true)
local_serve_port integer CLI-only: run locally during deploy

Schedule: schedule_name and cron (required), optional description, invoke_body, paused.

Deploy sets source_bundle_hash / built_bundle_hash — do not author them.

Workers (resources.workers[])

Field Type Meaning
name string Required
src string Source dir (default workers/{name})
runtime string binary (Go, default), nodejs, python, php
start_command array Override the container command
http_port integer Run your own HTTP server on this port
env_vars map Environment variables
memory_mb / cpu_cores number Default 512 MiB / 2 cores; max 1024 MiB / 8 cores
invoke_timeout_ms integer Max runtime per invocation
idle_timeout_ms integer Idle time before an instance is stopped
min_instances integer Warm instances (0 = scale to zero)
max_concurrent integer Concurrent requests pulled per instance
single_instance boolean At most one instance
volumes array Mounts: name, optional mount_path (default /volumes/<name>)
system_packages object { "catalog_version": 1, "names": ["..."] } from the platform package catalog
store_source_bundle boolean Store raw source (default true)

Expose a worker with an http route targeting { "kind": "worker" }. Websocket routes must target functions.

Field Type Required
name string yes (no /)
index_type string no (text default, vector)
sources array yes

Source fields: type (tables or buckets), name, fields (required for tables), and for buckets prefix, include_subfolders, max_size_bytes. One index can combine several tables and buckets. See Search.

Secrets (resources.secrets[])

Field Type Meaning
name string Required
description string
generate boolean Platform generates a random value
value string Literal, {{ref-env-file://KEY}}, or ref-secret://<id>

Set either generate or value. On deploy, values are stored and replaced with ref-secret://<id>. Reference in env vars with {{ref-secret-stack://name}}.

Inference endpoints (resources.inference_endpoints[])

Field Type Required
name string yes
runtime string yes
custom_image string no
model_bucket / model_prefix string no
memory_mb / cpu_cores integer no
runtime_args map no

Expose with a route targeting { "kind": "inference" }. Platform models need no endpoint — see the model catalog.

Alerting

{
  "resources": {
    "alert_channels": [
      { "name": "ops-email", "type": "email", "enabled": true, "config": { "recipients": ["ops@example.com"], "subject_prefix": "[prod]" } }
    ],
    "alert_rules": [
      { "name": "errors-spike", "rule_type": "log_scan", "enabled": true, "message_match": "ERROR", "window_seconds": 300, "threshold": 5, "channel_names": ["ops-email"] }
    ]
  }
}

Channel: name, type (email or webhook), enabled, config (recipients, subject_prefix for email; webhook_url, webhook_headers for webhooks).

Rule: name, rule_type (log_scan or resource_usage), enabled, optional services, zones, message_match, window_seconds, threshold, cooldown_seconds, repeat_seconds, channel_names.

Removing resources

Remove an entry from the file to delete it. kernstack plan lists it under Will delete:, and deploy refuses until you pass --confirm-destructive. Creates and updates run before deletes, so renames are safe.

Deletes cascade: a table drops its indexes; a bucket drops its policies, volumes, search sources, and versions; a route unbinds its domains and revokes its keys; a workload drops its routes and schedules.

Profile overlays

stack.<env>.json next to stack.json is merged over the base when that environment is active (profile my-app.qa → stack.qa.json).

{
  "stack": { "target": "my-app.qa", "space_id": "qa-space-id" },
  "resources": { "functions": [{ "name": "api", "env_vars": { "AUTH_PROJECT_ID": "qa-project-id" } }] }
}
  • Scalars: overlay wins when set.
  • Maps (env_vars, vars): merged by key.
  • Named lists (routes, resources, volumes): merged by name (domains by hostname + path_prefix); new entries are appended.

Overlay choice: explicit --profile / KERNSTACK_PROFILE, then the environment pinned by kernstack use, then the active profile.

Local env files (.stack.env)

Keep deploy-time values in gitignored .stack.env (merged with .stack.<profile>.env, e.g. .stack.my-app.qa.env, for the active profile) and reference them with {{ref-env-file://KEY}}. Missing keys fail the deploy.

DB_PASSWORD=replace-me
{ "resources": { "secrets": [{ "name": "db-password", "value": "{{ref-env-file://DB_PASSWORD}}" }] } }