sky.toml — project manifest reference

Status: the Rust compiler (rust/, cargo build --release -p sky) is the primary Sky compiler; the Haskell compiler is preserved under legacy-haskell-compiler/. Verified by the example sweep + compiler test suite (cargo test + xtask gates). See history/compiler/versions.md for the changelog.

Every Sky project has a sky.toml at its root. It declares metadata, build settings, dependencies, and runtime defaults. Created automatically by sky init; hand-edited as the project grows.

The format is TOML — sections in [brackets], key-value pairs underneath, comments with #. Section order does not matter.

Minimal example

[project]
name    = "my-app"
version = "0.1.0"

That's enough — every other field has a sensible default.

All sections at a glance

SectionPurpose
[project]Name, version, entry file, output binary name
[go.dependencies]Go packages to auto-bind via sky add
[dependencies]Sky-source dependencies (other Sky projects)
[live]Sky.Live runtime config (port, sessions, …)
[database]Std.Db default connection (the DSN selects the driver)
[jobs]Std.Jobs queue backend (v0.19.14+)
[log]Std.Log default format and level
[env]Env-var namespace prefix (v0.11.5+)
[security]CSRF opt-out
[app]Persisted build target for a Std.App entry (build-time, not runtime)
[tool.<name>]Another tool's own settings. Sky never reads it and never warns (v0.27.2+)

Cross-platform packaging (app name, bundle id, icon) is NOT in sky.toml. It lives in code, as an optional bundle binding built with Std.Bundle's withX API, so sky.toml stays lean — see ## Packaging identity below.

There is no [auth] section. Std.Auth is a library, not a framework layer — it takes the JWT secret and TTL as Sky arguments — so there is nothing to seed. The block was parsed, seeded and read by nothing for four minor versions; it was deleted, and a residual [auth] key now raises the standard inert-key build warning. See Std.Auth configuration.

Every key seeded into the runtime is only applied when the corresponding env var is unset. So shell env / .env always wins over sky.toml. Production deployments override config without editing files.

Tool sections — [tool.<name>] (v0.27.2+)

A tool that keeps its settings in the project's sky.toml (a code generator, a linter, a deploy script) puts them under [tool.<name>]. Sky never reads a [tool.*] section, its sub-tables ([tool.sqlgen.queries]) or arrays of tables ([[tool.lint.rules]]), and never warns about them:

[tool.sqlgen]
schema = "db/schema.sql"
out = "src/Db"

Any other section Sky does not read gets ONE build warning (not one per key). A near miss of a Sky section names the section it meant ([liv] → "Did you mean [live]?"). Any other unknown section is told to move under [tool.<name>]. An unknown key in a section Sky does read ([live] prot) still warns per key, with the accepted keys.


[project]

Project metadata. Top-level keys are also accepted (no [project] header required) for compatibility with older manifests.

[project]
name    = "my-app"        # used in error messages and the binary
version = "0.1.0"         # informational only
entry   = "src/Main.sky"  # default source file passed to sky build
root    = "src"           # source root for module resolution
bin     = "app"           # output binary name → sky-out/app
KeyTypeDefaultMeaning
namestring"sky-project"Project name (informational)
versionstring"0.1.0"Semver (informational)
entrystring"src/Main.sky"Default file for sky build / run
rootstring"src"Source-root prefix for module imports
binstring"app"Output binary name in sky-out/

bin and root are single names, not paths. A value with /, \ or .. (e.g. bin = "dist/fence") fails sky check and sky build with an error naming the key (v0.27.2+; it used to be replaced by the default without a word). To put the binary in another directory, keep bin a name and pass sky build --out <dir>: bin = "fence" with --out dist writes dist/fence.


[app]

The persisted build target for a Std.App entry — the backend a bare sky build / sky run / sky check picks when no --target is on the command line. It exists mainly for the terminal String-view shapes (App.cli / App.tui): their view : model -> String cannot render on web, so the web default would fail — pinning terminal:cli / terminal:tui here lets a bare build target the terminal instead.

[app]
target = "terminal:cli"   # or terminal:tui · desktop · web:app · mobile:ios · …
KeyTypeDefaultMeaning
targetstringwebDefault --target family[:variant] for this entry

This is a build-time marker (like [spa]), not runtime config — it is read by the CLI when resolving the target, never by the running app. An explicit --target on the command line always overrides it, and it only applies to a dispatched Std.App entry (one that imports Std.App and runs App.run).


[go.dependencies]

Go modules to auto-bind into Sky. Each entry maps the Go module path to a version pin (or "latest"). sky add writes here for you; sky install regenerates bindings to match.

[go.dependencies]
"github.com/google/uuid"        = "v1.6.0"
"github.com/joho/godotenv"      = "v1.5.1"
"github.com/stripe/stripe-go/v76" = "v76.20.0"

Generated bindings land under .skycache/ffi/ (Sky-side .skyi files) and .skycache/go/ (Go wrappers). Don't commit those — they're reproducible from sky.toml + the imported source.

Use sky remove <pkg> to drop a dependency cleanly. See ffi/go-interop.md for the FFI model.

A local Go module is an inline table with a path instead of a version. sky add ../greet writes it:

["go.dependencies"]
"example.com/greet" = { path = "../greet" }

The key is the module path its go.mod declares. A relative path is relative to the project root (the directory holding this sky.toml), never the working directory. Every build adds require + replace for it to the generated go.mod. See sky add ./dir.


[dependencies]

Sky-source dependencies — other Sky projects you want to import. Path or git URL → version. Resolved into .skydeps/ on sky install.

[dependencies]
"github.com/anzellai/sky-stripe" = "v0.2.1"

A local Sky package takes a path instead of a version (sky add ./libs/widgets writes it). It is loaded from its source root on every build; nothing is copied into .skydeps/:

[dependencies]
"widgets" = { path = "./libs/widgets" }

Less commonly used than Go deps; most reusable code in the ecosystem ships as Go modules so existing go.mod projects can consume them too.


[live]

Sky.Live (server-driven UI) runtime config. Every key seeds an env-var default at startup, namespaced by [env] prefix (default SKY_). These seeds sit BELOW an explicit builder call in code (App.withConfig on a Std.App entry, or the low-level Live.withX), which in turn sits below the operator's environment (shell or .env) — see Precedence. See the Sky.Live overview for the full picture.

[live]
port         = 8000              # HTTP listener port
store        = "sqlite"          # session store: memory / sqlite / redis / postgres
storePath    = "./sessions.db"   # file path or connection URL
ttl          = 1800              # session TTL in seconds (30 min)
static       = "public"          # static asset directory served at /static
maxBodyBytes = 5242880           # cap for /_sky/event POST body (5 MiB)
KeyEnv varDefaultMeaning
port<PREFIX>_LIVE_PORT8000HTTP listener port
store<PREFIX>_LIVE_STOREmemorymemory / sqlite / redis / postgres
storePath<PREFIX>_LIVE_STORE_PATH(empty)sqlite file path, or host:port / redis://… / postgres://… URL
ttl<PREFIX>_LIVE_TTL1800Session TTL in seconds
static<PREFIX>_LIVE_STATIC_DIR(empty)Static asset directory served at /static
maxBodyBytes<PREFIX>_LIVE_MAX_BODY_BYTES5242880Max /_sky/event POST body (bump for Event.onFile uploads)

Postgres falls back to DATABASE_URL and Redis to REDIS_URL when storePath is unset (Redis defaults further to localhost:6379).

Connection-status banner config is env-only (not in sky.toml): <PREFIX>_LIVE_BANNER (default on), <PREFIX>_LIVE_RETRY_BASE_MS (default 500), <PREFIX>_LIVE_RETRY_MAX_MS (default 16000), <PREFIX>_LIVE_RETRY_MAX_ATTEMPTS (default 10), <PREFIX>_LIVE_QUEUE_MAX (default 50).

Cross-instance pub/sub broker. <PREFIX>_LIVE_BROKER_URL (unset → in-process) points Cmd.publish at a shared Redis broker so a publish on replica A reaches subscribers on replica B — required for multi-replica Cmd.publish / cross-device fan-out. The broker is app-scoped, not store-scoped, so it works even with a non-Redis session store (e.g. Postgres sessions + Redis pub/sub). Set it in code with the builder Sky.Config.withLiveBroker "redis://host:6379" (operator env still wins); for the Sky.Spa auto-split backend, bake it with sky spa-split --broker <url> (env still overrides). <PREFIX>_LIVE_BROKER=inprocess forces the local registry back for a single-instance Redis deploy.

Session transport. <PREFIX>_LIVE_SESSION_TRANSPORT (env only, no sky.toml key) is cookie (the default: the sky_sid cookie) or header (the session token travels in the X-Sky-Session header, for hosts that cannot keep cookies; see Sessions without cookies). The builders are App.withSessionTransport HeaderToken and Live.withSessionTransport "header"; the operator variable wins over them. An unknown value keeps cookies and prints a warning. A server with Server.rpc routes (a Sky.Spa backend) refuses to start with header set, because those routes authenticate with the session cookie.


Std.Auth configuration (no [auth] section)

[auth] is not a sky.toml section. Std.Auth is a library, not a framework layer: signToken secret claims expirySeconds takes the secret + TTL as arguments, and the session cookie is set by your handler (Server.withCookie name value attrs resp, or Server.addCookie). There is nothing for the runtime to reconfigure, so there is nothing to seed. The block (driver / cookieName / tokenTtl) was parsed, seeded into SKY_AUTH_* env vars and read by nothing for four minor versions; it was deleted. A residual [auth] key now falls through to the standard inert-key build warning — it does nothing.

Configure Std.Auth from your code, reading whatever environment variables you choose at the call site:

secret = Secret.fromEnv "SKY_AUTH_TOKEN_SECRET"   -- opaque Secret; redacts in logs
ttl    = System.getenvOr "SKY_AUTH_TOKEN_TTL" "86400" |> String.toInt |> Result.withDefault 86400
cookie = System.getenvOr "SKY_AUTH_COOKIE" "sky_auth"

token  = Auth.signToken secret claims ttl

These SKY_AUTH_* reads are a convention in your own code, not runtime settings — nothing in runtime-go/ reads them, and the compiler no longer seeds any of them from sky.toml. Set them in the environment (shell, .env, secret manager). Because your code reads them with System.getenv / System.getenvOr, the name is passed through raw — [env] prefix does not rewrite it (see [env]); choose whatever variable names you like.

The signing secret is never a config file value. SKY_AUTH_TOKEN_SECRET lives in the environment (shell, .env, secret manager), never in a committed file. It must be ≥ 32 bytes. sky init writes it into the generated .env (rust/crates/sky/src/main.rs:1300) and the production gate reads the literal, unprefixed name (main.rs:3791). Nothing reads SKY_AUTH_SECRET (prefixed or not); using that name silently fails the ENV=production gate.

Any key in a runtime config section that Sky does not read produces a build warning naming the accepted keys — including any leftover [auth] key from an older project.


[database]

Std.Db default connection. Db.connect () (unit form) reads <PREFIX>_DB_PATH to find the database — set this here once and all calls pick it up automatically.

The driver is derived from the connection string, not configured. A postgres:// / postgresql:// URL (or a libpq host=… user=… DSN) opens Postgres; anything else is a SQLite file path. That single rule is what the runtime applies, and every dialect-specific behaviour downstream follows from it.

[database]
path   = "./app.db"        # sqlite file path or postgres URL → the driver
# url  = "postgres://…"    # alias for `path` (same DB_PATH)
driver = "sqlite"          # OPTIONAL assertion — must agree with the DSN above
KeyEnv varDefaultMeaning
path<PREFIX>_DB_PATH(empty)File path or connection URL — selects the driver
url<PREFIX>_DB_PATH(empty)Alias for path (postgres DSN)
driver(none)(unset)Optional consistency assertion; see below
embedded(none)falsesky run supervises a local cluster and provisions the DSN — see below
postgresVersion(none)(unset)The PostgreSQL sky db provision --embed fetched and sky db start prefers

driver does not select anything. It is checked against path/url at build time and a contradiction is reported — driver = "postgres" beside path = "./app.db" warns that the app will open SQLite. To choose the engine at run time, set the DSN (SKY_DB_PATH / DATABASE_URL), not a driver name.

Before v0.19.9 this key emitted a <PREFIX>_DB_DRIVER env var that nothing in the runtime ever read, so a mismatched driver was silently ignored and the app quietly opened the other engine. The variable is no longer emitted.

Connection pool (PostgreSQL)

You should not need these. The runtime sizes the pool from the deployment it detects, and the defaults are chosen rather than inherited. Reach for them when you know your server's max_connections budget and how many app instances share it — that is a fact about your deployment which the app cannot see.

[database]
url             = "postgres://…"
maxOpenConns    = 12        # ceiling on simultaneous backends
maxIdleConns    = 12        # keep them; below open causes reconnect churn
connMaxLifetime = "30m"     # retire a connection so a failover heals
connMaxIdleTime = "5m"      # reap one that has gone quiet
KeyEnv varDefault (VM)Default (serverless)
maxOpenConns<PREFIX>_DB_MAX_OPEN_CONNS4 × CPU, clamped 4–322 × CPU, clamped 2–8
maxIdleConns<PREFIX>_DB_MAX_IDLE_CONNS= maxOpenConns= maxOpenConns
connMaxLifetime<PREFIX>_DB_CONN_MAX_LIFETIME30m30m
connMaxIdleTime<PREFIX>_DB_CONN_MAX_IDLE_TIME5m60s

Durations accept Go syntax ("30m", "90s", "1h30m") or a bare integer read as seconds. 0 disables a limit.

The sizing is deployment-aware because the right number is not a property of the app — it is a property of how many copies of it there are. On a VM the app is one process. On request-billed serverless the platform runs many small instances and each holds its own pool, so the per-instance number that is conservative on a VM is a connection storm across fifty of them. The runtime reuses the same K_SERVICE / AWS_LAMBDA_FUNCTION_NAME detection the telemetry exporter uses to vary its flush cadence. Force it either way with SKY_RUNTIME_MODE=serverless / =vm.

Two things follow from the serverless defaults that are worth knowing: the pool is small, so an operator running high per-instance request concurrency (Cloud Run defaults to 80) may genuinely need to raise maxOpenConns — and raising it is a decision about the server's connection budget, which is why it is explicit rather than automatic. And connMaxIdleTime is short, because a frozen instance keeps its TCP connections and therefore the PostgreSQL backend processes behind them alive while doing no work at all.

These keys are PostgreSQL-only. SQLite is pinned to a single connection by its global writer lock — raising it reintroduces the SQLITE_BUSY class — so setting them alongside a SQLite DSN logs a warning and changes nothing.

Before v0.20.3 none of this existed: the runtime clamped SQLite and let PostgreSQL fall through on Go's database/sql defaults, under a comment asserting those defaults were "already sane". They are MaxOpenConns = 0 (unlimited), MaxIdleConns = 2, and no connection lifetime — so a burst opened backends until PostgreSQL answered FATAL: sorry, too many clients already, and below that threshold the pool churned connections because only two stayed idle.

Transaction isolation

[database]
isolation = "serializable"   # default: the driver's own level
txRetry   = 3                # default: 0 — read the warning below first
KeyEnv varDefaultMeaning
isolation<PREFIX>_DB_ISOLATION(unset)Level Std.Db.transaction begins at
txRetry<PREFIX>_DB_TX_RETRY0Retry budget for a 40001 / 40P01 conflict

isolation accepts read uncommitted, read committed, repeatable read and serializable, in any case and with spaces, hyphens or underscores. Unset means the driver's own default, which on PostgreSQL is READ COMMITTED — that is the shipped behaviour and adding this key does not change it. Raising the default silently would start surfacing serialization failures to apps that have never seen one.

txRetry requires a replayable transaction body — the runtime cannot check this for you. Retrying a serialization failure means running the body AGAIN. A Task body may already have sent an email, charged a card or called a third-party API before the conflicting write was detected, and ROLLBACK undoes none of that: the database's half of the work is atomic, the outside world's half is not. Enable it only when every effect inside the body is either a write on the same transaction or genuinely idempotent. It is off by default for this reason.

Both keys are PostgreSQL-only. SQLite transactions already serialise on the single pooled connection, so there is no weaker level to ask for and no 40001 to retry; setting either alongside a SQLite DSN warns and changes nothing.

Embedded PostgreSQL

[database]
embedded = true             # sky supervises a local cluster and provisions the DSN
postgresVersion = "18.6"    # written by `sky db provision --embed`
KeyEnv varDefaultMeaning
embedded(none — a toolchain key)falsesky run / sky watch start a per-project PostgreSQL and inject its DSN
postgresVersion(none — a toolchain key)(unset)The PostgreSQL major/minor this project is developed against

With embedded = true, sky run starts this project's cluster (.skydata/pg/, a unix socket outside the project — see embedded PostgreSQL) and hands the app <PREFIX>_DB_PATH. The app is unchanged: it calls Db.connect () and reads a DSN, exactly as it does against a managed server. That is the point — the binary never learns which tier provisioned its database.

The lifetime follows the verb. sky run is ephemeral and stops the cluster when it exits, ref-counted so two concurrent runs do not stop each other's database. sky db start is persistent and stays up until sky db stop, including across a sky run that used it — that is the mode for running ./sky-out/app repeatedly.

Unlike every other key here, embedded and postgresVersion set no environment variable. They are read by the sky toolchain, not by the app.

postgresVersion is written by sky db provision --embed, which fetches Sky's own PostgreSQL build into ~/.sky/postgres/<version>/ (checksum-verified, installed atomically) so the project needs no system PostgreSQL. The pin is not decoration: binary discovery prefers the pinned version over a newer cached one, so a checkout on another machine gets the PostgreSQL the project states rather than whichever that machine provisioned last. SKY_POSTGRES_BIN still outranks it, and a pin with nothing provisioned for it is skipped — pin, then run sky db provision --embed (or sky doctor --fix) to fetch it.

embedded = true alongside path / url / SKY_DB_PATH / DATABASE_URL is an error, not a precedence rule. There is no safe answer: preferring the cluster means the app writes to a throwaway local directory while you believe it is talking to the server you named, and preferring the DSN means the opt-in is a line of configuration that does nothing. sky run names the offending source and both ways out, and refuses before it builds.

The cluster's postgresql.conf is generated from the machine it is starting on and re-rendered on every start, immediately before the postmaster spawns. That matters because max_connections and shared_buffers need a restart rather than a reload, and a restart is exactly what is about to happen — so resizing the host from 2 vCPU to 8, or restoring a data directory onto a different machine, retunes the cluster on the next boot instead of leaving it sized for the machine it was created on while the app's pools track the new one.

Only resource and planner-cost knobs are set; nothing that changes what a query means or how durable it is. Settings you add outside the managed block are preserved, and since PostgreSQL takes the last occurrence of a setting, anything after the block's end marker wins.

max_connections is sized from what one app process can actually demand — the app's own pool and the runtime's pools for analytics, Sky.Live sessions and telemetry — doubled to cover the window where a restarting process overlaps the one it replaces, plus PostgreSQL's reserved superuser slots and headroom for a psql session. You should not need to set it; --max-connections on sky db provision is there when you do.

maxOpenConns moves that number. The app's pool is the term every other one is a share of, so raising it raises the whole process's demand and the cluster follows: maxOpenConns = 64 on a 1-core host takes the process from 20 backends to 92, and the generated max_connections grows to cover it. The clamps above (a dev cluster's 100, a shared cluster's 500) bound what Sky derives from the machine; they do not overrule a number you stated, because a cluster smaller than the pool it was told about is an app strangling itself on its own configuration. The generated conf names the app-pool term it was sized for, so the arithmetic can be checked from the file. Setting maxOpenConns = 0 (UNLIMITED) is the one case no max_connections can cover — the cluster is sized for the default pool instead and the conf says so.

For this to work the knob has to be visible to the command that provisions the cluster, not only to the app: sky db start and sky run read sky.toml, the project's .env and their own environment, in the runtime's own precedence (environment, then .env, then sky.toml). A knob exported for the app's service unit alone is invisible to a sky db provision --shared run on the same host — state the cluster's size with --max-connections there.

A shared cluster (sky db provision --shared) is sized the same way with one extra factor: it serves every app on the host rather than one, so the per-process demand is multiplied by the apps a machine that size is expected to carry — one per four cores, capped at four, because a Sky process asks for four connections per core and expects to use several of them. Passing --max-connections overrides the derivation entirely; the flag is how an operator who genuinely runs a fleet states the number.

Analytics and telemetry writes

These sinks are batched behind a single buffered writer and trade a bounded crash-loss window for throughput. The full behaviour — the bounded queue, the drop policy and its counter, the shutdown flush, and connection sharing — is in observability.

Env varDefaultMeaning
SKY_ANALYTICS_SYNCHRONOUS_COMMIToffon makes analytics writes wait for the WAL fsync at commit. The default trades a few hundred ms of server-crash loss for throughput. Per-transaction (SET LOCAL) — never cluster-wide, and never applied to the app's own pool.
SKY_TELEMETRY_SYNCHRONOUS_COMMIToffThe same, for the console's log / metric / span writes. Builder: Sky.Config.withTelemetrySynchronousCommit True.
SKY_TELEMETRY_AGGREGATION_WINDOW0 (off)A Go duration (10s). When > 0, counter metric rows are coalesced to one per (name,labels) per window — a busy app writes one row per counter per window instead of one per interaction. Lossless for rate/delta; only sub-window resolution is lost. Builder: Sky.Config.withTelemetryAggregationWindow 10 (seconds).
SKY_TELEMETRY_HISTOGRAM_AGGREGATION_WINDOW0 (off)A Go duration. When > 0, a histogram is persisted once per window as cumulative OpenMetrics _bucket/_sum/_count rows instead of one raw row per observation. Lossy (bucket-resolution) and breaking for a raw-row reader — enable only with a bucket-aware reader. Builder: Sky.Config.withTelemetryHistogramWindow 10.
SKY_TELEMETRY_DB_CAPACITY(unset)Operator-declared DB capacity in human units (100GB, 1.5TB, 512MB). The hourly size report warns when the whole database exceeds 90% of it — the only "near full" signal for a remote DB whose host disk the app cannot see. Unset → size + growth, no capacity flag. Builder: Sky.Config.withTelemetryDbCapacity (Gigabytes 100).
SKY_ANALYTICS_DB_PATH.sky/analytics.dbWhere analytics persists. A postgres:// value puts it in that database; anything else is a local SQLite file. Falls back to SKY_CONSOLE_DB_PATH, then DATABASE_URL when that is a PostgreSQL DSN.
SKY_ANALYTICS_RETENTION(unset — keep everything)Delete events older than this. Go duration (720h) or a day form (90d).
SKY_LIVE_REVOCATION_CACHE_TTL0 (fresh read every gate eval)Per-replica cache window, in whole seconds, for the Live.withRevocation gate's revoked_at / disabled_at lookup. A positive value trades ≤TTL of revocation latency for fewer shared-table reads on the interaction hot path; 0 reads fresh every time (instant cross-replica revocation). A same-replica revokeUser / disableUser invalidates that user's entry immediately. Prefix-affected.

Garbage collection

There is no sky.toml knob for the collector, and that is deliberate. At startup the runtime derives GOMEMLIMIT from detected machine memory — the cgroup limit before /proc/meminfo, so a container is sized to itself and not to its host — after subtracting the OS and, under --embed, the cluster's own shared_buffers, and sets GOGC=400 under that bound. Measured at +19% throughput and 759 MB peak RSS at 500 concurrent sessions on the PostgreSQL store (docs/perf/runs/gogc-postgres-20260816/).

The escape hatch is Go's own, because that is the one that already exists and already works from a container image or a systemd unit that never reads sky.toml:

Env varDefaultMeaning
GOGC(derived — 400; Go's 100 on serverless and on machines too small for the bound)Go's own heap-growth multiplier. Set it and sky derives nothing for it.
GOMEMLIMIT(derived — three quarters of RAM after the OS and any embedded cluster)Go's own soft memory limit. Set it and sky derives nothing for it. Setting one of these does not suppress the other.
SKY_GC_QUIET(unset)Suppresses the one-line [sky.gc] startup banner on stderr. For a one-shot CLI whose stderr is somebody else's input; it does not change what is derived.

A value written into sky.toml would travel to machines it was not sized for, which is the whole reason the figure is derived at runtime rather than configured. Sizing detail, including the floor below which the runtime declines to tune at all: embedded PostgreSQL.


[jobs] (v0.19.14+)

Std.Jobs queue backend. Same shape as [live] store — the keys seed env defaults the runtime reads, and shell env still wins without a rebuild.

[jobs]
store     = "postgres"          # memory (default) / sqlite / postgres
storePath = "postgres://…"      # sqlite: file path · postgres: DSN
KeyEnv varDefaultMeaning
store<PREFIX>_JOBS_STOREmemorymemory / sqlite / postgres
storePath<PREFIX>_JOBS_STORE_PATH./_sky/jobs.dbsqlite path, or the Postgres DSN

store_path is accepted as a spelling of storePath, because that is the name the runtime's own error message used.

memory is single-instance and volatile — enqueued jobs are lost on restart and are never shared between replicas. That is fine for development and is a deliberate opt-in; it is not a default to deploy on. With ENV=production set, a sqlite/postgres store that cannot be opened is a hard startup failure rather than a silent fall back to the memory queue.

This section was referenced by the runtime's error messages and parsed by nothing until v0.19.14: setting [jobs] store did exactly nothing, while in production the app refused to start and told the operator to set the key they had just set.


[log]

Std.Log default format and threshold. Both seed env-var defaults; runtime env still overrides without recompile.

[log]
format = "json"            # plain (default) / json
level  = "info"            # debug / info / warn / error
KeyEnv varDefaultValues
format<PREFIX>_LOG_FORMATplainplain / json
level<PREFIX>_LOG_LEVELinfodebug / info / warn / error

Switch to JSON in production by setting <PREFIX>_LOG_FORMAT=json in the deployment env — no rebuild required.


[env] (v0.11.5+)

Namespace prefix for Sky's internal runtime env-var reads. The default prefix is SKY, so the runtime reads SKY_LIVE_PORT, SKY_AUTH_TOKEN_TTL, SKY_LOG_FORMAT, etc.

Projects running multiple Sky binaries on the same host can declare a private namespace to avoid collision:

[env]
prefix = "FENCE"

The compiler emits rt.SetEnvPrefix("FENCE") at the top of the generated init(). From there, the runtime reads FENCE_LIVE_PORT, FENCE_AUTH_TOKEN_TTL, FENCE_LOG_FORMAT, etc. The user's shell / .env / docker env supplies the prefixed names too.

KeyDefaultMeaning
prefixSKYNamespace for runtime env-var reads. Trims trailing _.

What's affected by the prefix:

This list is enforced, not aspirational: rust/crates/xtask/tests/sky_env_reads_honour_the_prefix.rs classifies every os.Getenv("SKY_…") in the runtime and fails the build on a read in a prefix-affected namespace that bypasses skyGetenv. Before that gate, SKY_LIVE_FRAME_ANCESTORS — the switch that puts SameSite=None; Secure on the session and CSRF cookies — was read raw, so a project with a custom prefix could not enable cross-origin embedding at all, and nothing said why.

What's NOT affected:

Backwards-compatible: omit [env] prefix and behaviour matches every prior Sky version exactly.

For values not known until runtime (derived from a startup flag, computed from another secret), use System.setenv name value from your code — it's a Task Error () returning helper that mutates the process env without Go FFI.


[security]

[security]
csrf = false     # default: true — leave it on unless you are sure
KeyEnv varDefaultMeaning
csrfSKY_CSRFtrueGlobal CSRF middleware on/off

csrf turns off Sky's global CSRF middleware. Leave it on for anything a browser talks to. The one case that justifies false is a purely-stateless API where every endpoint authenticates from a Bearer token in the Authorization header — a cross-origin page cannot add that header without a preflight, so the header itself is the CSRF defence. If any endpoint authenticates from a cookie, turning this off is a vulnerability.

Equivalent at runtime: SKY_CSRF=off (or false / 0).

There is no [security] env

Which environment a binary is running in is not a sky.toml key, and never has been. Set the ENV environment variable on the deployment:

ENV=production ./sky-out/app

ENV (or the namespaced <PREFIX>_ENV, e.g. SKY_ENV) is what gates the dev console, metrics auth, and the Secure attribute on session cookies. Anything other than dev / development / local counts as production.

The reason it is not a build-time key is that one binary gets promoted dev → staging → prod; a value baked in at compile time could not be right for all three. Writing [security] env into sky.toml now produces a build warning naming this variable.

Bind interface — SKY_HOST (v0.20.3+)

Which network interface the HTTP listener binds to (both Sky.Live and Sky.Http.Server). Like ENV, it is an environment variable, not a sky.toml key — the same binary binds differently across dev / staging / prod without a rebuild.

Env varDefault (dev)Default (prod)Meaning
<PREFIX>_HOST127.0.0.1all interfaces (:PORT)Interface to bind the listener to

The default is derived from ENV, not fixed:

# dev, reachable from your phone on the LAN:
SKY_HOST=0.0.0.0 sky run src/Main.sky

SKY_HOST is prefix-affected: under [env] prefix = "FENCE" the runtime reads FENCE_HOST.

The listening line keeps its old shape (Sky.Live listening on :8000, Sky server listening on http://localhost:8000) because tools parse it, so it does not say where the listener is reachable. The line under it does, in every mode (v0.27+):

  bind         127.0.0.1:8000  loopback (dev default; other Host names need SKY_ALLOWED_HOSTS)
  bind         0.0.0.0:8000  all interfaces (production default; SKY_HOST narrows)
  bind         10.0.0.5:8000  from SKY_HOST

When an open dev console is bound off loopback, the bind line adds console exposed off-host: set SKY_CONSOLE_AUTH.

Dev Host guard — SKY_ALLOWED_HOSTS (v0.27+)

Binding loopback keeps other machines out. It does not keep other websites out: a page on evil.example can point its own DNS name at 127.0.0.1 (DNS rebinding) and then read every response the local server sends, the open dev console included. The browser cannot hide the name it used, so the loopback listener checks the Host header.

When the listener is bound to a loopback address (the dev default, or SKY_HOST=127.0.0.1 / localhost / ::1), every request whose Host is not an allowed name gets 403, on every route: Sky.Live pages, SSE, Sky.Http.Server routes, the console, the Sky.Spa backend. Allowed without configuration:

Env varDefaultMeaning
<PREFIX>_ALLOWED_HOSTSunsetComma list of extra Host names the loopback listener answers. *.example.test matches every subdomain. * turns the check off. A port in an entry is ignored.
# a dev proxy that forwards app.test to the local server:
SKY_ALLOWED_HOSTS=app.test sky run src/Main.sky
# GitHub Codespaces port forwarding:
SKY_ALLOWED_HOSTS='*.app.github.dev' sky run src/Main.sky

A phone on the LAN needs SKY_HOST=0.0.0.0 (to reach the listener at all), and then the guard does not apply. The guard does not apply to any non-loopback bind (production, containers, SKY_HOST=0.0.0.0), because a reverse proxy may rewrite Host there.

SKY_ALLOWED_HOSTS also feeds the default WebSocket origin list: outside production, a Sky.Http.Server.WebSocket upgrade with no Ws.withOriginPatterns accepts a client with no Origin, a page on the same host, a loopback page on any port (localhost:5173), and a page on a host listed here (* is not turned into "any origin"). Every other origin gets 403. In production an upgrade with no withOriginPatterns is refused.

SKY_ALLOWED_HOSTS is prefix-affected (FENCE_ALLOWED_HOSTS).

Native shell backend address — SKY_APP_URL (v0.25.19+)

The native shells that sky build --target mobile:ios, mobile:android and desktop:<os> generate are thin web views over the Sky.Spa client, which the backend serves. SKY_APP_URL tells the build which backend address the shell loads. It is read by sky build, not by the app, and it is not prefix-affected.

The build resolves the address in this order. The first one that is set wins:

  1. SKY_APP_URL in the build environment.
  2. App.withAppUrl "<url>" on the entry's App value (see docs/skyapp/overview.md).
  3. The default: http://localhost:<PORT>/ for iOS (the simulator shares the host network), http://10.0.2.2:<PORT>/ for Android (the emulator's alias for the host), and http://127.0.0.1:<PORT>/ for desktop. PORT is read at build time and is 8951 when it is unset.
SKY_APP_URL=https://app.example.test/ sky build --target mobile:ios src/Main.sky

The value must be an absolute http:// or https:// URL with a host. The build refuses anything else (ftp://x, not a url, an empty value) and names where the value came from. It adds a missing trailing /. The build summary prints the result, for example loads https://app.example.test/ (SKY_APP_URL).

A phone cannot read the build machine's environment, so the iOS and Android shells bake the address in at build time. The desktop shell also reads SKY_APP_URL at run time, and that value wins over the built-in one.

Plain http:// to a host that is not local works, but the build prints a warning: a production device build should use https://. The iOS shell gets an App Transport Security exception (NSExceptionDomains) for exactly that host, and the Android shell gets a network security config that permits cleartext for exactly that host.

Native release signing — sky package --release (v0.27.0+)

sky package --release --target <t> reads its signing configuration from the environment only, because a keystore password never belongs in a tracked file. None of these is read by the app, and none is prefix-affected.

VariableTargetMeaning
SKY_IOS_SIGN_IDENTITYmobile:iosThe signing identity (security find-identity -v -p codesigning), e.g. Apple Distribution: Acme Ltd (TEAMID). Without it (and without the profile) the build makes an unsigned -unsigned.ipa.
SKY_IOS_PROVISIONING_PROFILEmobile:iosPath to the .mobileprovision file. Required with the identity; its identity keys go into the entitlements, and every entitlement the app requests must be one it grants.
SKY_ANDROID_KEYSTOREmobile:androidPath to the upload keystore. Required: a release is never signed with the debug key.
SKY_ANDROID_KEYSTORE_PASSWORDmobile:androidThe keystore password, passed to apksigner / jarsigner by variable name. Required.
SKY_ANDROID_KEY_ALIASmobile:androidThe key alias. Required.
SKY_ANDROID_KEY_PASSWORDmobile:androidThe key password, when it differs from the keystore password.
SKY_MACOS_SIGN_IDENTITYdesktop:macA Developer ID Application identity; signs the .app and .dmg with the hardened runtime. Without it the .app is signed ad hoc, and a restricted entitlement (a keychain access group, an associated domain, push, iCloud) is left out of the signature with a note, because macOS does not launch an ad hoc signed app that asks for one.
SKY_MACOS_PROVISIONING_PROFILEdesktop:macPath to the app's .provisionprofile. Required with the identity when the app asks for a restricted entitlement; it is embedded in the .app, and every entitlement the app requests must be one it grants.

sky package --release --target mobile:ios --upload testflight (also tablet:ipad) uploads the signed .ipa to App Store Connect with xcrun altool and reads its API key from the environment too:

VariableTargetMeaning
SKY_ASC_KEY_ID--upload testflightThe App Store Connect API key id (10 characters). Passed to altool as --api-key. Required.
SKY_ASC_ISSUER_ID--upload testflightThe issuer id (a UUID) from the API keys page. Passed as --api-issuer. Required.
SKY_ASC_KEY_PATH--upload testflightPath to the downloaded AuthKey_<KEY_ID>.p8. Required. The file never goes on a command line: altool reads it from the directory in API_PRIVATE_KEYS_DIR, and Sky never prints it.
SKY_XCRUN--upload testflightTest only. An executable run in place of xcrun, so the flow tests can prove the upload with a fake. Leave it unset.

SKY_PACKAGE_RELEASE is internal: sky package sets it to the release directory (<project>/sky-out/release) so that the child build legs build the release artefacts. Do not set it yourself.

A release also refuses the development backend address: SKY_APP_URL (or App.withAppUrl) must name a deployed https:// host. See docs/skyapp/native.md.

Content-Security-Policy — SKY_CSP (v0.25.19+)

Every page that Sky serves (Sky.Live, the Sky Console, Sky.Spa, Std.Ui forms) runs under script-src 'self' 'wasm-unsafe-eval' with no hashes, no nonces, no 'unsafe-inline' and no 'unsafe-eval': the scripts are same-origin files and per-page data is in <script type="application/json"> blocks. You can send that policy from a reverse proxy, or have the runtime send it:

Env varDefaultMeaning
<PREFIX>_CSP(unset)strict sends a strict Content-Security-Policy on every Sky.Live page, Sky.Http.Server response and static file that has none

With SKY_CSP=strict the policy is:

default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none';
base-uri 'self'; form-action 'self'; frame-ancestors 'self'

frame-ancestors takes the SKY_LIVE_FRAME_ANCESTORS list when that is set, and X-Frame-Options: SAMEORIGIN stays when it is not. Rules:

The strict policy blocks third-party scripts, images, fonts and API hosts. If the app loads any, send your own policy (it wins) instead of SKY_CSP=strict.

Public origin for RPC requests — SKY_PUBLIC_URL (v0.27+)

A Server.rpc route (every Sky.Spa /_rpc/<Msg> endpoint) accepts a browser request only when it comes from the app's own origin: the request carries Sec-Fetch-Site: same-origin, or an Origin header equal to the app's public origin. SKY_PUBLIC_URL names that origin.

Env varDefaultMeaning
<PREFIX>_PUBLIC_URL(unset)The URL the browser uses to reach the app, for example https://app.example.com. A comma-separated list accepts several origins.

Packaging identity — Std.Bundle (v0.21+)

The cross-platform app identity used by sky build --target ios|android|desktop — display name, reverse-DNS id, icon, version — is not a sky.toml section. It lives in code, so sky.toml stays lean and packaging sits next to the app. Declare an optional top-level bundle binding with the Std.Bundle withX builder:

-- doc-example: skip  (illustrative — init/update/view/subscriptions elided)
module Main exposing (main, bundle)

import Std.App as App
import Std.Bundle as Bundle exposing (Bundle)

bundle : Bundle
bundle =
    Bundle.default                          -- name = project dir, default Sky icon
        |> Bundle.withId "com.acme.notes"   -- YOUR reverse-DNS id (a domain you own)
        |> Bundle.withIcon "assets/icon.png"
        |> Bundle.withVersion "2.3.0"

appDef =
    App.app { init = init, update = update, view = view, subscriptions = subscriptions }
        |> App.withNotFound ()

main =
    App.run appDef

sky build --target … reads those withX values from the source (no runtime eval, no sky.toml) to fill the generated iOS Info.plist, the Android manifest (package + versionName + versionCode), and the desktop window title.

withXDefault (when unset)Maps to
withNameproject directory nameCFBundleDisplayName · android:label · desktop title
withIdsky.spa.<sanitised-dir-name> (dev)CFBundleIdentifier · Android package
withVersion1.0CFBundleShortVersionString · android:versionName
withIconplatform default iconapp icon — a source PNG rendered into the iOS AppIcon set + Android mipmaps (needs macOS sips)

Rules:

Full API: sky doc Std.Bundle.

Roadmap: per-platform overrides, a Bundle.assetBytes reader (bytes, not just a URL), a shipped default icon, native permissions, and release signing/notarization. --target ios still builds for the simulator and --target android signs with the debug keystore — neither is store-ready yet.


Precedence

Configuration values resolve in this order (highest priority first):

  1. System environment variables (export VAR=…, Docker ENV, k8s, CI vars).
  2. .env file in the working directory (auto-loaded at startup; never overrides existing env vars).
  3. Explicit builder calls in code — on a Std.App entry, App.withConfig (App.WebConfig { App.webDefaults | port = 8000, … }) and App.withBase …; on the low-level runtimes, the underlying Live.withPort / Live.withStore / Live.withStorePath / Live.withIdleEvict. A WebOpts field left at its webDefaults value is NOT a builder call: port = -1 (the default) means "not set", and csrf = True (the default) leaves the switch to the layers below. So [live] port applies to a Std.App web app that does not set a port.
  4. sky.toml defaults (compiled into the binary's init(); only set when the corresponding env var is unset).
  5. Hardcoded runtime fallbacks (e.g. port 8080, TTL 30m).

Standard godotenv / Docker convention: production deployments always win over .env and sky.toml so you can override settings without editing files.

Layers 1, 2 and 4 meet in the same environment variable — sky.toml keys are seeded into their env vars at startup — but the runtime records which values it seeded itself, so a sky.toml-derived default never counts as "the operator set this". The one rule, spelled out:

operator env (shell or .env) → withX builder call → seeded default (sky.toml / compiler) → hardcoded fallback

So an operator can always override the binary without a rebuild, and an explicit withX call in code always beats the sky.toml seed while still losing to the operator.


Typed config in code — Sky.Config

The cross-cutting settings above ([log], [database], [live] store, [jobs], [security] csrf, telemetry) can also be declared in Sky, as a top-level config binding the compiler discovers the way it discovers main:

-- doc-example: skip  (illustrative fragment; `main` is elided)
module Main exposing (main, config)

import Sky.Config as Config exposing (LogFormat(..), LogLevel(..), Database(..))

config : Config.Config
config =
    Config.default
        |> Config.withLog Json Warn
        |> Config.withDatabase (Postgres "postgres://localhost/app")
        |> Config.withSessions Config.SharedWithDatabase

main =
    ...

Each withX value is an ADT, so store = "postgress" becomes a compile error rather than a runtime fallback to memory. A withX value beats the legacy sky.toml seed and still loses to the operator's environment — the same one precedence rule as everything else (operator env → withX → sky.toml seed → fallback). Where a setting has both a Sky.Config.withX and a more-specific Live.withX (only the session store — withSessions vs Live.withStore/withStorePath), the app-shape Live.withX wins.

The full surface — default, withLog, withDatabase, withSessions, withJobs, withCsrf, withTelemetry, withLiveBroker, and the strategy ADTs — is the live API: sky doc Sky.Config (generated from source, never drifts). The design of record is config-architecture.md.

Console / telemetry tokens are deliberately NOT builders — a secret belongs to the deployment, not the source — so withTelemetry carries only the OTLP endpoint; the tokens stay operator-owned environment.

Migrating a legacy sky.toml — sky config migrate

When a build or run finds legacy runtime keys in sky.toml, the compiler prints a migration LIST (moved / removed / changed), self-extinguishing once the keys are gone. sky config migrate rewrites them into a typed config binding:

sky config migrate            # rewrite sky.toml → typed config, in place
sky config migrate --dry-run  # show the diff, write nothing
sky config migrate --check    # exit non-zero if legacy runtime keys remain (CI gate)

--check and --dry-run are mutually exclusive. Both the build-time hint and the verb derive from the same migration table, so what the hint names is exactly what the verb rewrites. See CLI reference.


Tooling

sky.toml is hand-editable any time — the compiler re-reads it on every build.