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:
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¶
| 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:
errors/404.htmlexists under the route'sprefix(or at the bucket root if no prefix is set): it is served with HTTP 404 for every missing path.- Otherwise, in the default
"spa"fallback mode, extensionless and.htmlpaths getdefault_keywith HTTP 200. This is the single-page-app fallback. - 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_prefixthe 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 inX-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,tomust 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_streamdisables the stream on the next deploy; adding it back starts fresh from that deploy. Changingfunctionrepoints 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[])¶
| 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.
Search (resources.search_indexes[])¶
| 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 byhostname+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.