Shipping
Deployment
tern build produces one static binary that contains the server, the client assets, prerendered pages and the server renderer. Deploying a Tern app means running that binary.
npm run build # or: go tool tern build
PORT=8080 ./build/my-app
The binary needs no Node.js, no files next to it, and no cgo, so it cross-compiles like any Go program:
GOOS=linux GOARCH=arm64 go tool tern build -o build/my-app-linux-arm64
#tern deploy
tern deploy builds the app and puts it on a server, on Fly.io, or in a container registry. Targets are named in tern.json:
{
"deploy": {
"production": {
"provider": "ssh",
"host": "root@203.0.113.5",
"domains": ["example.com", "www.example.com"],
"envFile": ".env.production"
}
}
}
go tool tern deploy # the only target, or: tern deploy production
go tool tern deploy -rollback # back to the release before
go tool tern deploy -releases # list the releases on the target
go tool tern deploy -logs # follow the app's logs
Without a tern.json entry, tern deploy -host root@203.0.113.5 -domain example.com deploys over SSH all the same.
Every target takes these fields:
provideris required:ssh,fly,container, or the name of an external provider.nameis the app's name on the target. The default is the project directory's.envandenvFileset the app's environment.envwins over the file. Keep the file out of git.domainsare the app's domains.
#To a server over SSH
The ssh provider needs a Linux server with systemd, any of the common distributions, and ssh on your machine. It uses your ~/.ssh/config, keys and agent, as ssh in a terminal does. On every deploy it:
- Connects and finds the server's architecture, then builds for it (
linux/amd64,arm64…). - Creates a system user for the app and a systemd unit,
<name>@.service, the first time. It needs root or sudo without a password. - Uploads the binary, compressed, as a new release in
/srv/<name>/releases/, with the target's environment in the release's own.env. A release that fails, or a later restart, never sees another release's settings./srv/<name>/sharedis the app's working directory, kept across releases: put SQLite databases there. Variables you keep on the server, outsidetern.json, go inshared/.env; the release's win over them. - Starts the new release next to the running one, on the same port, and waits until it reports that it is ready: its migrations and start functions have run and it is listening. Only then is the old release stopped, and it finishes the requests it has. No request is refused while this happens.
- Keeps the last five releases for
-rollback.
If the new release doesn't start, its log is shown, it is stopped and removed, and the old release keeps serving.
With domains, the app serves HTTPS itself on ports 443 and 80, with certificates from Let's Encrypt (HTTPS without a proxy), so the server needs nothing but systemd. Point the domains' DNS at the server first. Without domains, the app listens on port 3000 (appPort), for a proxy in front.
The ssh provider's own fields:
| Field | Default | |
|---|---|---|
host |
Where to connect: user@address, or a Host from ~/.ssh/config |
|
port, identity, sshArgs |
ssh's -p, -i, and more arguments |
|
dir |
/srv/<name> |
Where the app lives on the server |
user |
<name> |
The system user that runs the app |
appPort |
3000 |
The port without domains |
keep |
5 |
Releases kept |
The unit runs the app with ProtectSystem=strict: it can write only to shared, and binds ports below 1024 through CAP_NET_BIND_SERVICE alone.
#To Fly.io
"fly": { "provider": "fly", "app": "my-app", "region": "fra" }
The fly provider needs flyctl, signed in. It writes a fly.toml and a Dockerfile if the project has none, creates the app if it doesn't exist, stages env as secrets, and runs fly deploy. -releases and -logs work too.
#To a container registry
"image": {
"provider": "container",
"image": "ghcr.io/you/my-app",
"after": ["gcloud run deploy my-app --image {image} --region europe-west1"]
}
The container provider builds the Dockerfile with docker or podman, for linux/amd64 (platform), tags the image with the release's version and latest, and pushes both (noPush skips that). after runs commands once the image is pushed, with {image} replaced: the deploy command of Cloud Run, ECS, Azure Container Apps, Kubernetes or Railway.
#Providers of your own
Any other provider value runs a program named tern-deploy-<provider> from the PATH. A hosting platform can ship its own provider this way. The program gets the deployment as JSON on its standard input, and its output is shown:
{
"action": "deploy",
"target": "production",
"name": "my-app",
"root": "/home/you/my-app",
"version": "v1.4.0-3-g1a2b3c4",
"env": { "DATABASE_URL": "…" },
"domains": ["example.com"],
"config": { "provider": "acme", "project": "p_123" }
}
actionisdeploy,rollback,releasesorlogs.configis the target's entry fromtern.json, with any fields the provider wants.- The program builds the app as it needs.
GOOS=linux GOARCH=amd64 go tool tern build -o …cross-compiles, or it can use the Dockerfile. - A nonzero exit status fails the deploy.
#Before you deploy: tern doctor
go tool tern doctor checks a project for what goes wrong only in production:
✓ Go go1.26.2
✓ Node v22.11.0
✓ Tern v1.15.0 on both sides
✓ Routes 12 routes, generated
! Native rendering 1 module falls back to the JavaScript engine, such as src/lib/Chart.svelte: …
✓ Migrations 4 migrations, applied when the app starts
! Environment production doesn't set SESSION_SECRET, from .env.example
✓ HTTPS served by the app for app.example.com
✓ Security headers, Content-Security-Policy, CSRF protection and rate limits on
✗ Go modules 2 known vulnerabilities the app's code reaches: 2 in Go's standard library
Update to Go 1.27.4; go run golang.org/x/vuln/cmd/govulncheck@latest ./... shows them
✓ npm packages no high or critical advisories (2 lower)
✓ Git everything committed
- Tools: Go as new as
go.modasks, and Node 22 or later for the build. - Tern: the Go module and the npm package are the same version; they're released together.
- Routes and migrations: code generation succeeds, and the migrations are safe to deploy: none fails on a table with rows or breaks the version still running (database migrations).
- Native rendering: what the last
tern buildleft to the JavaScript engine. - The target in
tern.json(the only one, orproduction; or name one,tern doctor staging): its environment sets every variable in.env.example, and it serves HTTPS, withdomainsor behind a proxy. - Security: options that turn off a protection Tern has on by default, and what each costs (docs/security.md).
- Known vulnerabilities: govulncheck, built into Tern, on the Go modules and the Go version, counting only what the app's code reaches, which fails the check; and
npm auditorpnpm auditon the npm packages, whose high and critical advisories are warnings.tern doctor -offlineskips both. - Uncommitted changes, which a release would be named after.
It exits 1 when a check fails, so CI can run it before deploying; warnings don't fail it. -json prints the checks for scripts.
#Configuration
| Variable | Default | Meaning |
|---|---|---|
PORT |
3000, or 443 with HTTPS |
Port to listen on |
HOST |
all interfaces | Address to listen on, e.g. 127.0.0.1 behind a local proxy |
TERN_TLS_DOMAINS |
Serve HTTPS for these domains, comma-separated, with certificates from Let's Encrypt (see HTTPS without a proxy) | |
TERN_TLS_EMAIL, TERN_TLS_CACHE |
none, tern-certs |
The email given to Let's Encrypt, and the directory certificates are kept in |
TERN_LOG_LEVEL, TERN_LOG_FORMAT |
info, JSON |
See logging |
Everything else is configured in main.go with kit options:
app, err := ternApp(
kit.WithLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil))), // structured logs
kit.WithTrustedOrigins("https://admin.example.com"), // cross-origin form posts
kit.WithBodyLimit(10<<20), // 10 MiB uploads
kit.WithSSRWorkers(4), // concurrent server renders
)
#Behavior in production
- Shutdown. On
SIGINTorSIGTERMthe server stops accepting connections and lets in-flight requests finish for up to 10 seconds. - Caching. Content-hashed assets under
/_app/immutable/are served withCache-Control: public, max-age=31536000, immutable. Other static files and prerendered pages use ETags and revalidate. Dynamic pages areprivate, no-cache. - Compression. Text assets are precompressed with gzip at build time and served to clients that accept it.
- Security. Cross-origin POST, PUT, PATCH and DELETE requests from browsers are rejected (
http.CrossOriginProtection). Request bodies for actions and endpoints are limited to 1 MiB by default. Every response hasX-Content-Type-Options: nosniff. Unexpected errors are logged and shown to users only as "Internal Error". - Server rendering.
tern buildcompiles the server renderer to Go, and pages and emails render natively, one worker per CPU by default. What the compiler leaves to the JavaScript engine (listed in.tern/native/report.txt) renders in embedded QuickJS VMs, as does any page whose native render fails. A render that fails both ways or takes longer than 5 seconds falls back to client rendering and is logged, so one broken component cannot take a page down. - Checking native rendering.
TERN_NATIVE_VERIFY=1renders every page both ways, serves the JavaScript engine's HTML and logs where native rendering differs.TERN_NATIVE=0turns native rendering off, at run time or fortern build;"native": falseintern.jsondoes too. One difference is expected: a page that prints a function's source (fn.toString(), as mode-watcher's theme script does) gets the original source natively and the minified bundle's from the engine. Both run the same. - Binaries without the JavaScript engine. When the compiler leaves nothing to the engine (the report is empty),
tern buildleaves QuickJS and the server bundle out of the binary, a tenth of its size, and says so: "Compiling the server without the JavaScript engine". Pages, emails and live components all render natively. Should a render still fail natively, the page renders in the browser instead, and is logged.TERN_KEEP_ENGINE=1 tern buildkeeps the engine, whichTERN_NATIVE_VERIFYneeds; so does a-tagsof your own inGOFLAGS.
#Behind a reverse proxy
Tern works behind nginx, Caddy, Traefik, or a cloud load balancer:
- The proxy must pass the
Hostheader through unchanged. - If the proxy terminates TLS, it must set
X-Forwarded-Proto: https. Tern uses it to mark cookiesSecureand to build absolute URLs for server rendering. - Leave
OriginandSec-Fetch-Siteintact; cross-origin protection reads them. - An
X-Request-Idthe proxy sets is kept as the request's ID in the logs. - Tell Tern which addresses are your proxies, so it can find the client's real IP:
app, err := ternApp(kit.WithTrustedProxies("127.0.0.1", "10.0.0.0/8"))
ctx.ClientIP(): returns the client's address; middleware can callkit.ClientIP(r). The rate limiting plugin uses it.- Which address it picks: if the request came from a trusted proxy, it reads
X-Forwarded-Forfrom the right and returns the first address that isn't a proxy. Entries further left were written by the client and could be anything, so they're never used. A request from any other peer gets its own address. X-Forwarded-Proto: once proxies are configured, it is only believed from them.- Without the option:
ClientIPis the proxy's address, andX-Forwarded-Protois believed from anyone.
Example Caddyfile:
example.com {
reverse_proxy 127.0.0.1:3000
}
#HTTPS without a proxy
A Tern app can serve HTTPS itself, with certificates from Let's Encrypt that it gets and renews on its own. No nginx or Caddy is needed in front:
app, err := ternApp(kit.WithAutoTLS(kit.AutoTLS{Domains: []string{"example.com", "www.example.com"}, Email: "ops@example.com"}))
or, with no code, TERN_TLS_DOMAINS=example.com,www.example.com.
- HTTPS is served on port 443, and plain HTTP on port 80. Port 80 answers Let's Encrypt's challenges and redirects everything else to HTTPS.
- The machine must be reachable at those domains. Certificates are kept in
tern-certs(AutoTLS.Cache), which must survive restarts, or Let's Encrypt's rate limits stop new ones. - Ports below 1024 need privileges: on Linux,
setcap 'cap_net_bind_service=+ep' ./app, orAmbientCapabilities=CAP_NET_BIND_SERVICEin a systemd unit.
kit.WithTLS(cfg) serves HTTPS with certificates of your own. HTTP/2 is offered in both cases. Serving TLS itself lets the app see the client's TLS fingerprint, which security signals use.
#Docker
Projects created with tern new include a Dockerfile that builds in a Go + Node image and ships the binary on distroless/static:
docker build -t my-app .
docker run -p 3000:3000 my-app
The final image contains only the binary and runs as a non-root user.
#systemd
# /etc/systemd/system/my-app.service
[Unit]
Description=my-app
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/opt/my-app/my-app
Environment=PORT=3000 HOST=127.0.0.1
User=my-app
Restart=on-failure
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now my-app
#Fly.io
# fly.toml
app = "my-app"
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 3000
force_https = true
[[http_service.checks]]
method = "GET"
path = "/"
interval = "15s"
timeout = "5s"
#Health checks
Add an endpoint for your platform's health checks:
// src/routes/healthz/server.go
package healthz
import "github.com/Unfield/Tern/kit"
func GET(ctx *kit.Ctx) error { return ctx.Text(200, "ok") }
#Serverless and edge platforms
Tern apps are long-running HTTP servers. Platforms that run containers (Cloud Run, Fly.io, Railway, Render, ECS, Kubernetes) work out of the box. Function platforms that expect a request handler can mount the app, since *kit.App is an http.Handler, using an adapter such as aws-lambda-go-api-proxy.