Operate
Deploy
Synsema ships as a single self-contained binary — no runtime on the target (Linux: a glibc binary with a 2.17 floor since v0.6.21, see below). The serve block stays dev-clean in the repo; deployment knobs are CLI flags, so the same file runs locally and in prod without edits.
synsema serve app.syn # dev: :8080, plain HTTP, no setup
synsema serve app.syn --port 443 --domain example.com,www.example.com --tls-auto admin@example.com # prod: HTTPS
Hosted instead? Everything on this page is for running Synsema on a machine of your own. The Synsema platform is the other way: syn deploy from the folder, a URL back, and secrets, logs, the capability audit and human gates handled for you. The program is the same either way. Serve flags§
| Flag | Effect |
|---|---|
--port N | Overrides serve on N and grants serve(N). |
--domain d1,d2 | ACME domains (one certificate per name since v0.6.42). |
--tls-auto <email> | Automatic HTTPS (ACME) — this is the dev↔prod switch; needs a domain. |
--tls-cert / --tls-key | Manual TLS (mutually exclusive with --tls-auto). |
--bind <addr> | Bind address (default 0.0.0.0). |
--health <path> | (engine v0.6.20+) Opt-in health endpoint for the host: GET <path> → 200 {ok, uptime_s, in_flight, engine}, no auth, no rate limit, absent from discovery. The same as SYNSEMA_HEALTH_PATH in the environment; a declared route at that path wins (warning at start). |
--sandbox / --cap-set "<list>" | Host ceiling for the whole server (engine v0.6.7+): handlers, cron ticks and spawned agents can require only within it. --sandbox = stdout,time + serve. |
Precedence: CLI flag > file clause > default. No --tls-auto → plain HTTP (dev); --tls-auto → TLS (prod).
Per-environment config (keep the repo dev-clean)§
The .syn file never changes between your laptop and the server, so git pull on prod never conflicts. Everything that differs lives outside the code.
Server knobs → CLI flags. --port, --domain, --tls-auto (table above). The flag wins over the file clause.
App values → the environment. Anything you read with env("NAME", default) — e.g. the canonical public URL that feeds your canonical/OG/sitemap tags:
require env("SITE_URL")
let SITE be env("SITE_URL", "http://127.0.0.1:8080") -- dev default; prod overrides
Set it on prod in the environment (Environment=SITE_URL=https://example.com under systemd, -e SITE_URL=… in Docker). Resolution is process env > .env > code default, so no repo edit is needed.
Runtime knobs → the process environment (not .env). SYNSEMA_SERVE_WORKERS=N sizes the pool of interpreter workers that handle requests (default: one per core, min 2). Raise it for I/O-bound handlers — more requests in flight, at the cost of more RAM. Unlike app values, this knob configures the runtime itself, so it is read once, from the process environment, before the first server starts — Environment= under systemd, -e in Docker, export in a shell. Putting it in .env does nothing: .env only feeds env()/secret() inside your program. Since engine v0.6.40 the workers share one frozen copy of the program's top-level globals, so an extra worker costs a few MB; up to v0.6.39 each worker built its own copy and RAM grew with globals × workers — there, with large in-memory data keep N low (see Performance and memory).
The same rule covers every server knob (engine v0.6.7+): SYNSEMA_SHUTDOWN_GRACE (drain seconds on SIGINT/SIGTERM, default 10), SYNSEMA_SSE_KEEPALIVE (15), SYNSEMA_WS_SERVER_PING (30), SYNSEMA_WS_SUBPROTOCOLS, SYNSEMA_WS_MAX_MESSAGE (16MB), SYNSEMA_WS_MAX_CONNS (4096), SYNSEMA_PROC_MAX (64), SYNSEMA_WATCH_MAX (64, live watch handles per interpreter, v0.6.9+). synsema init lists them commented in .env.example with that warning; the table with meanings is in Agentic apps. Two host knobs that are not server knobs live in that table too: SYNSEMA_STATE_DIR (where memory("NAME") keeps its .db — point it at a volume and keep the code dir read-only; process environment only) and SYNSEMA_LLM_BUDGET (per-process LLM token ceiling; the ops degrade to a marker, never an error) — see Memory and LLM primitives. Three more since engine v0.6.20: SYNSEMA_LLM_BUDGET_PER_IDENTITY="id=N,…" (token ceilings per authenticated identity under serve), SYNSEMA_HEALTH_PATH (the health endpoint of the table above) and SYNSEMA_AUDIT=json|<path>|fd:N|unix:<path> (the capability audit stream without a flag — how a container turns it on, also for a synsema build binary). systemctl stop / docker stop send SIGTERM → ordered shutdown (drain, exit 0) — set TimeoutStopSec above the grace.
Two apps on one host? Give each its own port — the code stays identical: synsema serve api.syn --port 8081 and synsema serve admin.syn --port 8082. Since --port overrides serve on N and grants serve(N), nothing in either file changes.
HTTPS, step by step (free, auto-renewing cert)§
--tls-auto gets a free certificate from Let's Encrypt (ACME) and auto-renews it — no certbot, no cron. What you need:
1. A domain pointing to the server's IP (a DNS A/AAAA record). 2. Ports 80 and 443 reachable — port 80 answers the one-time ACME challenge, then redirects to 443. 3. Run with the domain + a contact email:
synsema serve app.syn --port 443 --domain example.com,www.example.com --tls-auto you@example.com
On first boot Synsema obtains the certs, serves HTTPS on 443, redirects HTTP→HTTPS, and auto-renews each one ~30 days before it expires. Certs, their keys and the ACME account are stored (SYNSEMA_CERT_DIR → ~/.synsema/certs), so a restart reloads them (no re-issue → no rate-limit).
Since engine v0.6.42:
- One certificate per name (it used to be one certificate for the whole
--domainlist):
adding a domain issues only that one. On the first start after upgrading, names that were only in the old shared certificate get their own — mind the CA's rate limits with many names.
- The server starts even if the CA cannot be reached. A name without a certificate is logged
and retried in the background (every minute while a domain name is missing; each name backs off after a failure, 1 min doubling up to 1 h). Each issuance has a 2-minute limit. A certificate on disk is reused only if it names the domain and has not expired; keys are written atomically, 0600 in Unix (keep SYNSEMA_CERT_DIR in the service user's own profile — on Windows the folder's ACL applies), and checked against their certificate.
- Wildcards —
tls dns <task>.*.example.comcan only be proven over DNS-01. The engine
calls your task (name, value, action): with "set" it publishes a TXT record name = value at your DNS provider, with "clear" it removes it. The engine knows no provider — the task calls its HTTP API, so it needs its own require net(...). SYNSEMA_ACME_DNS_WAIT (20 s) is the wait for the record to propagate. A wildcard domain without tls dns is refused.
- Your customers' own domains —
domain ask <task>. When a handshake arrives for a name
without a certificate, the engine asks the task; true issues it on the spot (HTTP-01 on :80), anything else — an error, or no answer within 10 s — refuses it. There is no on-demand issuance without ask: approve only names you know are yours (look them up in your database), or anyone can make you request certificates. ask runs at most 8 at a time and 20 new names per second, and SYNSEMA_ACME_MAX_PER_HOUR (20) caps on-demand issuance. A name is asked again before it is renewed.
require serve(443)
require net("api.cloudflare.com")
task publish_txt(name, value, action)
-- call your DNS provider's API: create ("set") or delete ("clear") the TXT record
give true
task allow_host(host)
give ends_with(host, ".customers.example") -- or: is it in your table of custom domains?
serve on 443
tls auto "admin@example.com"
domain ["example.com", "*.example.com"]
tls dns publish_txt
domain ask allow_host
route "GET /"
give "hello"
The ACME challenge listens on port 80 (HTTP-01), which the CA must reach from the public internet. If something else already owns that port — another server, a container mapping, a sidecar — move it with SYNSEMA_ACME_HTTP_PORT=8080 and forward external :80 to it. Like the other runtime knobs it is read from the process environment, not .env.
Already have a cert? Use --tls-cert cert.pem --tls-key key.pem instead (mutually exclusive with --tls-auto). The flags are the operator's and need no capability. The same files can be named in the program, as a clause of the serve block — then the program reads them, so it declares both (engine v0.6.45+; without them the server does not start: Capability not granted: file_read("certs/cert.pem")):
require serve(443)
require file.read("./certs/cert.pem")
require file.read("./certs/key.pem")
serve on 443
tls cert "./certs/cert.pem" key "./certs/key.pem"
route "GET /"
give "hello over TLS"
Under systemd, give the service a writable HOME/StateDirectory (below) so it can store certs.
synsema daemon vs systemd — pick one§
synsema daemon start app.syn— built-in background manager (status/logs/stop/restart). No OS config, but no boot-start, no crash-restart. Good for dev / no-systemd boxes.- systemd — OS supervisor: boot-start (
enable),Restart=always, journald logs. Use for production.
[Service]
ExecStart=/usr/local/bin/synsema serve /opt/app/app.syn --port 443 --domain example.com --tls-auto admin@example.com
Restart=always
StateDirectory=synsema # writable HOME for ~/.synsema/certs fallback
Jobs and schedules. A job run with synsema run (a systemd unit, a container, a cron line) writes print, log and show line by line since v0.6.29, so journald and docker logs see each line when it happens instead of all of them at exit. For a schedule inside a long-running serve, cron_every(expr, task, {"tz": "America/Santiago"}) (v0.6.30+) fires at the local time of that zone all year, whatever the server's own clock is set to — see Cron.
Multiple sites on one host (Synsema is its own edge proxy)§
Two processes can't both bind :443, and you don't need nginx/Caddy. One Synsema process is the edge: it terminates TLS for every domain (one certificate per name since v0.6.42) and routes by Host to each backend, which runs plain-HTTP on a private port.
-- edge.syn — TLS + Host routing for every site on the box
require serve(443)
require net("127.0.0.1") -- deny-by-default: the edge only talks to localhost
serve on 443
host "example.com"
route "GET /" -- root: /*path does NOT match "/"
proxy to "http://127.0.0.1:8080"
route "GET /*path"
proxy to "http://127.0.0.1:8080"
route "POST /*path"
proxy to "http://127.0.0.1:8080"
host "docs.example.com"
route "GET /"
proxy to "http://127.0.0.1:8791"
route "GET /*path"
proxy to "http://127.0.0.1:8791"
route "POST /*path"
proxy to "http://127.0.0.1:8791"
Run the edge with a certificate for each domain; each backend runs plain-HTTP, localhost-only, with its own repo/version/service:
synsema serve edge.syn --port 443 --domain example.com,docs.example.com --tls-auto admin@example.com
synsema serve app.syn --port 8080 --bind 127.0.0.1
synsema serve docs.syn --port 8791 --bind 127.0.0.1
- Root gotcha:
route "GET /*path"needs ≥1 segment — it does not match/. Addroute "GET /"too (per method) so the home page reaches the backend. - Per method:
routebinds method+path — declare each method you forward (GET, POST, …). proxy toforwards status + content-type + body and the upstream's end-to-end headers (Location,Set-Cookie,Cache-Control,ETag, …), so redirects, cookies and caching work through the edge; hop-by-hop are dropped.- Streams cross the edge. A backend
streamroute (SSE) arrives event by event; a backendsocketroute (WebSocket) is tunnelled after the101; big downloads stream with theirContent-Length. So a chat backend and a plain API can each be their own process — ownrequirecontract, own port, own deploy — behind one TLS edge. The edge setsX-Forwarded-For/-Proto/-Hostfor the backend; since v0.6.42 a backend (Synsema or not) should keep them only from the edge — in a Synsema backend,trust proxy ["127.0.0.1"], orip of requestis the edge's address. Targets arehttp://only (TLS ends at the edge — anhttps://target fails at startup, not per request). Tunnels and unsized responses count againstmax_streamsand are closed by the ordered shutdown. - Independent deploys: restart one backend without touching the others; each can run its own Synsema version behind the same edge.
- *Name the backends, never
net("")* (engine v0.6.46+). A wildcardnetreaches only public addresses, andproxy tochecks the address on every connection: an edge withrequire net("") and backends on127.0.0.1starts, but answers each request502 {"error": "proxy error: upstream not allowed", "status": 502}and logs the reason.require net("127.0.0.1")(as above), the backend's private host, or its CIDR (net("10.0.0.0/8")) is what reaches them. The same holds for a--cap-set netceiling: name the private host there too (--cap-set "…,net=127.0.0.1"). - An edge that publishes nothing of its own —
discovery off(engine v0.6.46+): with that line in the serve block,/llms.txt,/openapi.json,/sitemap.xml,/robots.txt,/docs, the Agent Cards and/.well-known/synsema-authare not generated by the edge; aGET /*pathforwards them to the backend, which answers its own. See serve.
One binary with your program baked in (synsema build)§
synsema serve app.syn needs the .syn (and its modules/templates/assets) present at runtime. synsema build app.syn -o app folds all of that into the executable — the engine plus your program, sealed with a sha256 — so the deliverable is a single file that carries nothing on the outside (see CLI § synsema build):
synsema build app.syn -o app --include data/ # bundle extra assets (the serve block's static mounts go in by themselves, v0.6.16+)
synsema build app.syn -o app --cap-set "stdout,net=api.example.com,serve=8080" # bake a ceiling
synsema build app.syn -o app --audit json # bake the capability audit (or SYNSEMA_AUDIT=json ./app at run time, v0.6.20+)
synsema build job.syn -o job --deterministic # pure profile + stdout-only ceiling: reproducible runs (v0.6.20+)
synsema build app.syn -o app --serve --bind 0.0.0.0 --port 8080 # a SERVER binary (v0.6.16+): serve runtime + baked deploy flags
synsema build desk.syn -o desk --serve --no-console --icon icon.svg --bundle # a DESKTOP app (v0.6.18+): see "Your app on the desktop"
./app # runs the program; argv is the program's
Because there's nothing to mount, the image can be a minimal base that carries a libc (the binary is glibc-linked — FROM scratch has no libc and does not work):
FROM gcr.io/distroless/cc-debian12
COPY app /app
ENTRYPOINT ["/app"]
Linux binary and glibc. The Linux release is a dynamically linked glibc binary, not a static one. Since engine v0.6.21 it is built against glibc 2.17, so it runs on any distribution from 2014 on (Ubuntu 20.04/22.04/24.04, Debian 11/12/13, RHEL 7+, Amazon Linux 2, the debian:*-slim and gcr.io/distroless/cc images) and the release fails if that floor ever rises. v0.6.20 was the exception: it was linked on the runner's glibc 2.39 and only starts on Ubuntu 24.04+/Debian 13+ — on anything older it fails with GLIBC_2.39 not found; upgrade to v0.6.21. macOS and Windows binaries have no such floor. FROM scratch does not work (there is no libc in it) — use gcr.io/distroless/cc-debian12 or debian:bookworm-slim; install.sh runs the binary once after installing and tells you plainly when a system is too old.
The baked --cap-set/--profile travel with the binary and can't be raised from inside; app --engine version reaches the plain engine; app --engine update is refused (rebuild instead). For cross-target images, build against a donor engine binary with --engine-binary ./synsema-linux-x86_64. This is the most self-contained deploy: distributing app is distributing the whole program and its guardrails as one artifact. The same build, with bind "127.0.0.1", --no-console, --icon and --bundle, is a desktop app — Your app on the desktop.
Docker & Kubernetes§
The image is just the Synsema binary — the same prebuilt binary the site installs, on a base that has glibc (debian:bookworm-slim; it needs engine v0.6.21+ — the v0.6.20 binary wanted glibc 2.39 and does not start on bookworm). The Dockerfile in the repo fetches it from the GitHub release and verifies its checksum (it does not compile); you mount your .syn program into /app:
docker build -t synsema . # or --build-arg SYNSEMA_VERSION=v0.4.9
docker run -d --restart unless-stopped -e ANTHROPIC_API_KEY=sk-... \
-v "$PWD":/app -w /app -p 8080:8080 synsema serve app.syn
In K8s, command: ["synsema", "serve", "/app/app.syn"] and inject keys via secretKeyRef. For a liveness/readiness probe, set SYNSEMA_HEALTH_PATH=/healthz in the container environment and point the probe at it — the program declares nothing (engine v0.6.20+). Secrets/config come from the environment in prod (overrides .env) — see Secrets.
Rotating secrets without a restart (Unix, engine v0.6.20+)§
On Linux and macOS, kill -HUP <pid> (under systemd: ExecReload=/bin/kill -HUP $MAINPID, then systemctl reload) makes a running server re-read its .env on the next secret()/env() resolution and rebuild the LLM provider with the new key — no restart, no dropped connections. A secret you copied into a let at startup is a value and keeps the old content: resolve secrets where you use them. The process environment is read live and needs no signal. Windows has no SIGHUP: there, the graceful restart stays the way.
Updating§
A running server does not auto-update. synsema update swaps the binary on disk; systemctl restart applies it. TLS certs persist (stored + auto-renewed), so a restart reloads them — no Let's Encrypt rate-limit hit.