Environment variables
Every runtime + build-time knob, with defaults + accepted values.
The single source of truth for every runtime + build-time knob Casual
Sheets reads. The admin panel (lands in v0.1.0) reads this doc to
render its config UI; docs/DOCKERHUB.md and the site self-hosting
section both link here.
Two flavours:
- Runtime — read by the Node server at startup or per-request.
Settable via
docker run -e, theenvironment:block indocker-compose.yml, or--env-file. - Build-time — read by Vite during
pnpm --filter @sheet/web buildand baked into the frontend bundle. Setting these at runtime does nothing; pass them as--build-argondocker buildor via theargs:block ofdocker-compose.ymlto bake your own image.
Server (runtime)
| Var | Default | Description |
|---|---|---|
PORT | 3000 | HTTP + WebSocket listen port. The single port the image exposes. |
HOST | 0.0.0.0 | Bind address. 0.0.0.0 exposes to the network; 127.0.0.1 keeps it localhost-only. |
REDIS_URL | unset | Redis connection string for Y.Doc persistence (rooms survive server restarts with a 7-day TTL). Unset → in-memory only; rooms vanish on restart. Compose pattern: redis://redis:6379. |
ROOM_TTL_MIN | 15 | Minutes a room stays in memory after the last client disconnects. Lower → quicker eviction (less idle memory); higher → friendlier reconnect after a short drop. |
MAX_UPLOAD_MB | 100 | Hard cap on multipart + raw-binary uploads. Bounds the share-room seed (.xlsx) and snapshot (gzipped JSON) upload paths. Raise for users with workbooks above this — and bump VITE_MAX_OPEN_MB to match so the browser doesn’t post something the server will 413. |
NODE_ENV | production (in image) | Standard Node mode. Set by the Dockerfile; rarely overridden. |
Storage (runtime · landing in v0.1.0)
Reserved keys for the WOPI host-integration MVP. Currently no-op
on main; documented here so operators can plan ahead.
| Var | Accepted | Description |
|---|---|---|
CASUAL_STORAGE | memory (default) · local · s3 · postgres | Selects the WOPI backend. memory keeps today’s no-DB shape. The other three persist workbooks across restarts. |
CASUAL_LOCAL_PATH | /data | Filesystem root when CASUAL_STORAGE=local. Mount with -v ./workbooks:/data. |
CASUAL_S3_ENDPOINT | unset | S3-API endpoint when CASUAL_STORAGE=s3. Examples: https://s3.amazonaws.com, http://minio:9000, https://<account>.r2.cloudflarestorage.com. |
CASUAL_S3_REGION | us-east-1 | S3 region. Required by AWS S3; safe to keep at default for MinIO / R2 / B2. |
CASUAL_S3_BUCKET | unset | Bucket name. |
CASUAL_S3_ACCESS_KEY | unset | S3 access key. Treat as secret. |
CASUAL_S3_SECRET_KEY | unset | S3 secret key. Treat as secret. |
CASUAL_S3_FORCE_PATH_STYLE | false | Set true for MinIO and some self-hosted S3 implementations that require path-style addressing. |
CASUAL_PG_URL | unset | Postgres connection string when CASUAL_STORAGE=postgres. Format: postgres://user:pass@host:port/db. |
Networking (runtime · landing in v0.1.0)
Reserved keys for the admin-panel networking surface. Currently no-op
on main.
| Var | Default | Description |
|---|---|---|
CASUAL_PUBLIC_ORIGIN | detected | The public URL the server should report in redirects, WOPI BaseFileName, share-link generation, OG canonical URLs. Example: https://sheets.acme.example.com. |
CASUAL_CORS_ORIGINS | empty (same-origin only) | Comma-separated origins that may call the API. Empty → same-origin only. Example: https://app.acme.example.com,https://staging.acme.example.com. |
CASUAL_TRUST_PROXY | loopback | Which proxy hops we accept X-Forwarded-* from. false to disable; true to trust the immediate upstream; a list of IPs / subnets for explicit allowlisting. |
CASUAL_HSTS_MAX_AGE | unset | Emit Strict-Transport-Security: max-age=<value> when set. Only set if HTTPS terminates upstream — sending HSTS over HTTP locks users out. |
Admin (runtime · v0.1.0)
The admin panel at /admin is gated by env-driven credentials. Set
CASUAL_ADMIN_USERNAME + CASUAL_ADMIN_PASSWORD to enable; the
panel POSTs them to /api/admin/login which constant-time-compares
against env and mints a short-lived admin-role JWT for the session.
CASUAL_JWT_SECRET must also be set (≥ 16 chars) so the session
token can be signed.
| Var | Default | Description |
|---|---|---|
CASUAL_ADMIN_USERNAME | unset (panel disabled) | Operator login username for the admin panel. Compared constant-time against /api/admin/login body. |
CASUAL_ADMIN_PASSWORD | unset (panel disabled) | Operator login password. Treat as secret. |
CASUAL_ADMIN_SESSION_TTL | 3600 | Admin session JWT lifetime in seconds. |
CASUAL_ADMIN_CONFIG_PATH | /data/casual-admin.json | Filesystem path where the admin panel persists its JSON config (branding, storage, networking, room limits, auth-hook config, base path, webhook subscriptions). Atomically written with mode 0600 — treat the file as a secret. |
Admin REST endpoints
| Path | Method | Auth | Description |
|---|---|---|---|
/api/admin/status | GET | public | { configured: bool } — bootstrap probe for the panel. |
/api/admin/login | POST | env creds | Returns { token, ttl_seconds, username }. Token is an admin-role JWT. |
/api/admin/config | GET | admin JWT | Current config; secret fields redacted to ***. |
/api/admin/config | PUT | admin JWT | Patch-merge the on-disk config. Secret fields sent as *** keep the prior value. |
Admin config — runtime overrides
The on-disk config persisted at CASUAL_ADMIN_CONFIG_PATH overrides
env at runtime. Env provides the bootstrap floor; the admin panel
writes win once set. Fields:
- branding —
appName,accentColor,logoUrl - basePath — reverse-proxy mount path (e.g.
/sheets); empty means served at root. Affects Fastify route prefix + the SPA’s asset base. - storage —
backend(memory|local|s3|postgres)- per-backend creds. Mirrors the
CASUAL_STORAGEenv above.
- per-backend creds. Mirrors the
- networking —
publicOrigin,corsOrigins,trustProxy,hstsMaxAge. Mirrors the networking env vars. - limits —
maxRooms,maxFileSizeMb,roomTtlMin,maxUsersPerRoom. - auth — OIDC / SAML / JWT provider config (OIDC + SAML stub in v0.1; JWT actively wired).
- webhooks — array of
{ name, url, events, secret, enabled }subscriptions. See Webhooks below.
Webhooks (runtime · v0.1.0)
Server-side events fire HTTP POSTs to operator-configured URLs.
HMAC-SHA256 signs the JSON body when a subscription has a secret
— receivers verify via the X-Casual-Signature: sha256=<hex>
header.
Subscriptions live in admin-config.webhooks (writable via the
admin panel). Each subscription:
{
"name": "audit-log",
"url": "https://example.com/hooks/casual",
"events": ["file.saved", "admin.login"],
"secret": "shh",
"enabled": true
}
Empty events array = subscribed to every event.
Events
| Event | Fired when |
|---|---|
room.created | POST /api/rooms creates a new room |
room.dropped | Last client leaves + GC ticks (after roomTtlMin) |
file.uploaded | POST /api/rooms/:id/seed succeeds |
file.saved | POST /wopi/files/:id/contents succeeds (download → edit → save flow) |
file.deleted | DELETE /wopi/files/:id (admin only) |
user.joined | New client joins a room |
user.left | Client disconnects from a room |
admin.login | Successful /api/admin/login |
admin.login_failed | Failed /api/admin/login |
Payload shape
{
"event": "file.saved",
"timestamp": "2026-06-01T14:23:09.123Z",
"payload": {
"fileId": "wb-q3-budget",
"size": 12345,
"version": "1748872989123-abc12345",
"user": "alice@acme.example"
}
}
Headers on every dispatch:
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | CasualSheets-Webhook/0.1 |
X-Casual-Event | The event name |
X-Casual-Attempt | 1 or 2 — see retry policy below |
X-Casual-Signature | sha256=<hex> when subscription has a secret. Compute the same way to verify: hmac-sha256(secret, raw_body). |
Retry policy (v0.1)
- Single retry on non-2xx response or network error.
- Retry fires after 5 s.
- After 2 attempts, the dispatch is logged + dropped. v0.2 adds a proper queue with exponential back-off + a dead-letter store.
Auth (runtime · JWT access tokens · v0.1.0)
When CASUAL_JWT_SECRET is set, every /wopi/files/* request must
carry a valid JWT (in Authorization: Bearer … or via the
?access_token=… query string — WOPI’s standard placement). When
unset, WOPI routes fall through to v0.0.x anonymous-by-URL behaviour
— operators opt in to auth by setting the secret.
| Var | Default | Description |
|---|---|---|
CASUAL_JWT_SECRET | unset (auth disabled) | HMAC-SHA256 shared secret used to sign + verify access tokens. Minimum 16 chars; recommend ≥ 32 random bytes (e.g. openssl rand -hex 32). Treat as secret. |
CASUAL_JWT_DEFAULT_TTL | 3600 | Default token lifetime in seconds when ttl_seconds is omitted from the POST /api/tokens body. |
Token claims (signed payload)
| Claim | Type | Description |
|---|---|---|
sub | string | Username, email, or any stable user identifier. Surfaces as UserId in CheckFileInfo. |
file_id | string | The single file this token authorises. WOPI routes reject when the URL :id ≠ this claim. |
role | 'admin' | 'editor' | 'commenter' | 'viewer' | Coarse role. Default permission map applied unless permissions overrides. |
permissions | object (optional) | Per-flag override: { read, write, comment, download, share, admin }. |
features | object (optional) | Feature toggles consumed by the client UI: { charts, pivots, conditionalFormatting, sharing, exportFiles, collab, ai }. |
password_required | boolean (optional) | When true, the legacy x-room-password header gate also applies on top of the JWT. |
display_name | string (optional) | Human label for presence + cursor markers. Falls back to sub. |
aud | string (optional) | Audience — typically the deployment’s public origin. |
iss | string (optional) | Issuer — useful when downstream SSO mints tokens. |
exp, iat | number | Standard JWT expiry + issued-at, set by the signer. |
Endpoints exposed when JWT is configured
| Path | Method | Auth | Description |
|---|---|---|---|
/api/me | GET | optional | Decodes the token + returns resolved role / permissions / features / passwordRequired / exp. Returns { anonymous: true } without a token. |
/api/tokens | POST | admin role required | Mint a new token. Body: { sub, file_id, role, permissions?, features?, password_required?, display_name?, ttl_seconds?, aud?, iss? }. Returns the signed JWT + the resolved claim summary. |
/api/files | GET | admin role required | List every file id the host backend knows about. |
Bootstrapping the first admin token
Tokens are minted by tokens — chicken-and-egg. Sign the first admin token manually using the secret:
docker compose exec app node -e '
import("jsonwebtoken").then(({ default: jwt }) => {
const tok = jwt.sign(
{ sub: "owner", file_id: "*", role: "admin" },
process.env.CASUAL_JWT_SECRET,
{ algorithm: "HS256", expiresIn: "8h" },
);
console.log(tok);
});
'
After that, the admin panel (v0.1.0) issues subsequent tokens through its UI; the bootstrap step happens once per deployment.
Web build (Vite — bake-time only)
These are read at pnpm --filter @sheet/web build and bundled into
the JS. Override via --build-arg. Setting them at runtime does
nothing.
| Var | Default | Description |
|---|---|---|
VITE_COLLAB_ENABLED | 1 | Ship co-editing in the bundle. Off in the GitHub Pages demo build; on in the Docker image. |
VITE_COLLAB_WS_URL | same-origin /yjs | WebSocket URL the collab driver dials. Override when running Vite dev (:5273) against a standalone server (:3000). |
VITE_MAX_OPEN_MB | 100 | Hard reject for File → Open / drag-drop. Larger files freeze and eventually OOM-crash the tab during the ExcelJS parse. The supported sweet spot is 25–50 MB. |
VITE_SOFT_WARN_MB | 25 | Threshold above which the loading overlay shows the up-front “this is a large workbook, may take 10+ s” hint. Should be ≤ VITE_MAX_OPEN_MB. |
OCI image-label build args
Passed by .github/workflows/docker-publish.yml at tag-time. Surface
as org.opencontainers.image.* labels on the published image so
operators can docker inspect provenance.
| Build arg | Sets label | Notes |
|---|---|---|
CASUAL_VERSION | image.version | The git tag, e.g. v0.1.0. |
CASUAL_GIT_SHA | image.revision | Full commit SHA at the tag. |
CASUAL_BUILD_DATE | image.created | RFC 3339 UTC timestamp at build time. |
Inspect with:
docker inspect schnsrw/casual-sheets:latest \
| jq '.[0].Config.Labels | with_entries(select(.key | startswith("org.opencontainers")))'
Discovery convention
- Server keys use snake-case (
PORT,ROOM_TTL_MIN) for backwards compatibility with the v0.0.x release line. - Storage / networking / admin keys (v0.1.0+) all carry the
CASUAL_prefix so they’re greppable and don’t collide with generic env vars on a shared host.
If you’re adding a new runtime knob, follow the CASUAL_* convention
and update this file in the same commit. The admin panel auto-renders
its config UI from this table.
Synced from docs/ENV.md in schnsrw/sheets. To update: edit upstream and re-run npm run sync-docs.