Deploying a machin web app
A machin web service is one static-ish native binary (it links libc, OpenSSL, and maybe libsqlite3 — not fully static, but no runtime/interpreter). The standard production shape is that binary behind a reverse proxy that terminates TLS — nginx, Caddy, Traefik, or Cloudflare. You do not need machin to speak TLS itself; the proxy does HTTPS and forwards plain HTTP to your app. This skill is about making the app correct and safe in that setup.
serve_tls(port, certfile, keyfile, handler) (framework/machweb.src) lets machweb
terminate HTTPS itself with no proxy in front — for a simple/internal service where you
already have a cert+key file and don't want another moving part. It's not yet a
replacement for the proxy setup below for anything public-facing: no ACME/Let's Encrypt
auto-renewal (bring your own cert, renew it yourself), and res.is_hijack/res.is_stream
(protocol upgrades, SSE) aren't supported over it yet — use serve behind a proxy for
those. Reach for serve_tls when you want zero infra for a small/internal HTTPS service;
reach for the reverse-proxy shape below for anything public production-facing.
Build the app with the
machin-web/machin-backendskills; this is the operate it in production how-to.
Turn on the production knobs (in main)
All default off, so dev is unchanged. Enable what you need before serve:
func main() {
harden(20 * 1024 * 1024, 15000) // body cap 20 MB, 15 s read timeout, +trust-proxy,
// +Secure cookies, +access log — the common set
serve(8080, func(req) { return handle(req) })
}
harden(max_body_bytes, read_timeout_ms) is shorthand; the individual switches are:
| Call | Effect |
|---|---|
set_trust_proxy(1) |
trust X-Forwarded-Proto / X-Forwarded-For (only behind a proxy you control) |
set_secure_cookies(1) |
add ; Secure to every cookie (you're served over HTTPS) |
set_max_body(n) |
reject a request body larger than n bytes with 413 — without buffering it |
set_read_timeout(ms) |
cap a slow client's request read (anti slow-loris) |
set_access_log(1) |
one JSON access-log line per request on stderr (fd 2) |
Be proxy-correct
Behind a TLS-terminating proxy the socket is plain HTTP, so without help your app thinks
every request is http:// and sees the proxy's IP. With set_trust_proxy(1):
scheme(req)→http/httpsfromX-Forwarded-Proto. Use it so redirects, OAuthredirect_uris, and emailed links arehttps, nothttp.base_url(req)→scheme://host— build absolute URLs that survive the proxy.client_ip(req)→ the real client (left-mostX-Forwarded-Forhop), for logging / rate-limiting / audit.req.remoteis the raw socket peer (the proxy) if you need it.set_secure_cookies(1)→ sessions getSecureso they're never sent in clear.
Only trust these headers behind a proxy that sets them — a client can forge
X-Forwarded-*. If the app is ever directly exposed, leave set_trust_proxy(0).
Reverse proxy snippets
nginx — forward the headers machweb reads, and don't buffer SSE:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off; # let Server-Sent Events stream (machweb also sends X-Accel-Buffering: no)
}
Caddy — TLS + the forwarded headers are automatic:
app.example.com {
reverse_proxy 127.0.0.1:8080
}
Run it: systemd
A single binary is a trivial unit. Type=simple, run as a non-root user, restart on
failure, and rely on set_access_log(1) → journald:
[Unit]
Description=machin app
After=network.target
[Service]
ExecStart=/opt/app/server
Environment=PORT=8080 APP_SECRET=change-me
User=app
Restart=on-failure
RestartSec=2
# SIGTERM stops it; in-flight requests are short-lived goroutines.
[Install]
WantedBy=multi-user.target
listen sets SO_REUSEADDR, so a restart rebinds immediately (no TIME_WAIT wait).
Run it: Docker
By default the binary needs libc + OpenSSL (libssl/libcrypto, for sessions/crypto) and
libsqlite3 (if you use SQLite) at runtime, so ship it on a slim base:
FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 libsqlite3-0 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY server /usr/local/bin/server
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/server"]
(Multi-stage: build with machin build in a builder stage, copy just the binary.)
FROM scratch for a SQLite-only service: machin build --static compiles the SQLite
amalgamation in (no libsqlite3); with CC=musl-gcc you get a libc-free ~1 MB binary that
runs from an empty image — FROM scratch / COPY server / ENTRYPOINT. This works when the
program uses no OpenSSL — i.e. no HTTPS client (https_*) and no crypto builtins
(hmac_sha256/sha256/rand_bytes/…, which signed sessions use). Those still link OpenSSL
(so the slim base above) until native TLS lands (issue #260).
Gotchas
- Trust the proxy, not the client.
set_trust_proxy(1)only behind a proxy that overwritesX-Forwarded-*. Directly exposed → keep it off, or a client spoofs its IP. set_secure_cookies(1)requires HTTPS. On a Secure cookie set over plainhttp://the browser drops it — turn it on only once TLS (via the proxy) is in front.- Access logs go to stderr so stdout stays a clean data/JSON stream; capture fd 2 in journald/Docker.
- Body cap is on the declared
Content-Length— an over-cap request is413'd before its body is read (no OOM, no draining megabytes). - Graceful shutdown is SIGTERM = exit. Each request is its own short-lived goroutine, so kill is safe in practice; there's no long-drain coordinator yet.
Reference
- machin-deploy — a production-ready machin service wired with all of the above: proxy-correct, hardened, a systemd unit, a slim Dockerfile, and nginx/Caddy configs. Clone it as a deploy template.