---
slug: 71-deploy
title: Deploy
description: Deploy Synsema as a single static binary — daemon vs systemd, automatic HTTPS via CLI flags, Docker and Kubernetes — with a dev-clean .syn that needs no edits for prod.
example_ids: []
---

# Deploy

Synsema ships as a **single static binary** — no runtime on the target. 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**.

```sh
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
```

## Serve flags

| Flag | Effect |
|---|---|
| `--port N` | Overrides `serve on N` **and** grants `serve(N)`. |
| `--domain d1,d2` | ACME SAN domains. |
| `--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`). |
| `--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:

```synsema
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.

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](/en/0.6.x/47-agentic-apps). `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:

```sh
synsema serve app.syn --port 443 --domain example.com,www.example.com --tls-auto you@example.com
```

On first boot Synsema obtains the cert, serves HTTPS on 443, redirects HTTP→HTTPS, and auto-renews ~30 days before the 90-day expiry. Certs are stored (`SYNSEMA_CERT_DIR` → `~/.synsema/certs`), so a restart **reloads** them (no re-issue → no rate-limit).

**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`). 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.**

```ini
[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
```

## 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 SAN cert) and routes by `Host` to each backend, which runs plain-HTTP on a private port.

```synsema
-- 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 SAN cert for all domains; each backend runs plain-HTTP, localhost-only, with its own repo/version/service:

```sh
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 `/`. Add `route "GET /"` too (per method) so the home page reaches the backend.
- **Per method:** `route` binds method+path — declare each method you forward (GET, POST, …).
- `proxy to` forwards 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 `stream` route (SSE) arrives event by event; a backend `socket` route (WebSocket) is tunnelled after the `101`; big downloads stream with their `Content-Length`. So a chat backend and a plain API can each be their own process — own `require` contract, own port, own deploy — behind one TLS edge. The edge adds `X-Forwarded-For`/`-Proto`/`-Host`; targets are `http://` only (TLS ends at the edge — an `https://` target fails at startup, not per request). Tunnels and unsized responses count against `max_streams` and 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.

## 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`](/en/0.6.x/70-cli)):

```sh
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 --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 `FROM scratch`/distroless:

```dockerfile
FROM scratch
COPY app /app
ENTRYPOINT ["/app"]
```

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](/en/0.6.x/41c-desktop).

## Docker & Kubernetes

The image is **just the Synsema binary** — the same prebuilt static binary the site installs. 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`:

```sh
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`. Secrets/config come from the **environment** in prod (overrides `.env`) — see **[Secrets](/en/0.6.x/21-secrets)**.

## 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.
