Deploy
One binary behind a TLS-terminating proxy. Set these before it faces the internet; the proxy, service unit and multi-instance notes follow.
On this page
#Production checklist
| Setting | Set it with | Left unset |
|---|---|---|
| Session key | VIA_SESSION_KEY or via.WithSessionKey, at least 16 bytes. Generate with openssl rand -hex 32. | A random key per process: every cookie dies on restart and no other pod accepts it. |
| Session store | via.WithSessionStore | A map in this process. A restart logs everyone out even with a fixed key. |
| Trusted origin | via.WithTrustedOrigin | Fails open: actions accept requests from every origin, including ones that carry no origin signal at all. via logs one warning at startup. |
| Secure cookies | via.WithSecureCookies | Secure follows TLS, or the proxy's X-Forwarded-Proto or Forwarded header. Unneeded behind Caddy, or nginx configured as below. |
| Stream cap | via.WithMaxSSEConn | 10000 streams per router; the next connect answers 503. |
| Per-client limits | The proxy, below | One client can open streams until the router-wide cap refuses everyone. |
| Idle timeouts | Proxy and balancer, above 25 s; via.WithPinnedDeadline below the balancer's request timeout | The proxy cuts idle streams, or answers a stuck action before via can. |
| Readiness probe | Your own handler, below | The balancer keeps sending new tabs to a pod that is shutting down. |
origin := os.Getenv("VIA_ORIGIN")
if origin == "" {
return errors.New("VIA_ORIGIN unset")
}
// No WithSessionKey: via reads VIA_SESSION_KEY from the environment.
r := via.NewRouter(
via.WithTrustedOrigin(origin),
via.WithSecureCookies(),
via.WithSessionStore(SQLSessions{DB: db}),
via.WithLogger(slog.New(slog.NewJSONHandler(os.Stderr, nil))),
via.WithMaxSSEConn(5000),
)
via.Mount(r, "/", Home{})
via uses the bytes of VIA_SESSION_KEY as they are; it does not hex-decode them. The 64 characters openssl rand -hex 32 prints are a 64-byte key, and a key under 16 bytes panics when the router is built. Rotating the key logs every session out.
#Reverse proxy
example.com {
# text/* would also compress, and so buffer, text/event-stream.
encode zstd gzip {
match {
header Content-Type text/html*
header Content-Type text/css*
header Content-Type text/plain*
header Content-Type text/javascript*
header Content-Type application/javascript*
header Content-Type application/json*
header Content-Type image/svg+xml*
}
}
reverse_proxy 127.0.0.1:8080 {
# SSE: never buffer the upstream stream.
flush_interval -1
}
}# In the http block: open requests per client address, each open stream
# included. Clients behind one NAT share an address, so leave headroom.
limit_conn_zone $binary_remote_addr zone=perclient:10m;
server {
listen 443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/ssl/example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/example.com/privkey.pem;
location / {
limit_conn perclient 50;
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE: pass each frame on as it arrives.
proxy_buffering off;
# Must exceed via's fixed 25 s keepalive.
proxy_read_timeout 60s;
}
}A tab's stream is one long text/event-stream response to a POST. A proxy that buffers or compresses it holds every patch back, and the page never updates while clicks still POST. Do not strip a path prefix: action and stream URLs sit under each mount, so the upstream has to see the path the browser used. With nginx, forward Host and X-Forwarded-Proto: without a trusted origin match, via's same-origin check compares the browser's Origin with Host, and the proto decides whether the session cookie is Secure. Caddy sends both as is.
#Limits and dead peers
Limit connections and request rates per client at the proxy: via sees only the proxy's address. nginx does it with limit_conn (above) and limit_req. Stock Caddy has neither; build in the rate_limit module with xcaddy, or limit at the balancer or firewall in front.
Behind a proxy, via's 25 s keepalive detects a dead proxy, not a dead browser. A browser that vanishes without closing its connection is the proxy's to notice, through a failed write or TCP keepalive (Caddy's keepalive_interval). The proxy then closes the upstream request, and via ends the tab's stream.
#Timeouts
srv := &http.Server{
Addr: cmp.Or(os.Getenv("VIA_ADDR"), "127.0.0.1:8080"),
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
// WriteTimeout stays 0: it bounds the whole response, and a stream is
// one response for the life of the tab.
}
| Timer | Value | What it means for you |
|---|---|---|
| Keepalive frame | 25 s, fixed | The longest a healthy stream stays silent. Every proxy and balancer idle timeout on the path must be longer: 60 s is enough. |
| Frame write | 10 s, fixed | A peer that stops reading loses its stream after this. |
| Pinned deadline | 5 s, via.WithPinnedDeadline | How long an action waits, in all, for a stream still connecting (410 if it never comes) and for its tab's goroutine to pick it up (503). Keep it under the balancer's request timeout so the answer is via's. |
http.Server.WriteTimeout | 0 | It bounds the whole response, and a stream is one response for the life of the tab. |
The keepalive is the only way via notices a peer that vanished without closing the connection, which is why it cannot be turned off or slowed down. Behind a proxy that peer is the proxy; see Limits and dead peers.
#Health and readiness
var ready atomic.Bool
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
io.WriteString(w, "ok\n")
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
if !ready.Load() {
http.Error(w, "draining", http.StatusServiceUnavailable)
return
}
io.WriteString(w, "ok\n")
})
mux.Handle("/", r)
via ships no health endpoint; the app owns both. /healthz answers while the process is up. /readyz answers 503 from the moment shutdown starts, so the balancer stops sending new tabs to this pod before its streams close. Mount them on a mux in front of the router so a probe never touches sessions.
#Shutdown order
serve := make(chan error, 1)
go func() { serve <- srv.ListenAndServe() }()
ready.Store(true)
select {
case err := <-serve:
return err
case <-ctx.Done():
}
stop()
ready.Store(false)
time.Sleep(drainDelay)
shut, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return errors.Join(r.Shutdown(shut), srv.Shutdown(shut))
Fail readiness, then wait
Long enough for the balancer to mark the pod down: probe interval times failure threshold.
Shut the router down
via.Router.Shutdownends every stream the way a closed tab ends, with a clean end of response, and runs eachvia.Ctx.OnDispose. It returns once the last stream goroutine is gone. An action in flight answers normally or 410; one still waiting for its stream, and a connect after it, answer 503. Calling it twice is fine.A handler blocked in your code cannot be stopped from outside, so its stream ends only when the handler returns. If the deadline comes first, Shutdown returns the context's error and logs the tabs still blocked.
via.Router.Closeis Shutdown with no deadline.Shut the server down
http.Server.Shutdowndrains the plain requests, under the same deadline. It does not cancel the router's streams: called first, it waits on tabs that never end.
Five seconds of drain and five shared by both Shutdowns fit inside the 15 s the systemd unit below gives the process to stop (TimeoutStopSec).
#Service unit
[Unit]
Description=myapp
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
ExecStart=/usr/local/bin/myapp
Environment=VIA_ADDR=127.0.0.1:8080
Environment=VIA_ORIGIN=https://example.com
EnvironmentFile=/etc/myapp.env
User=myapp
Restart=always
RestartSec=2
TimeoutStopSec=15
NoNewPrivileges=yes
CapabilityBoundingSet=
UMask=0077
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallFilter=@system-service
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target#!/sbin/openrc-run
description="myapp"
supervisor=supervise-daemon
command=/usr/local/bin/myapp
command_user=myapp:myapp
respawn_delay=2
respawn_max=0
output_log=/var/log/myapp.log
error_log=/var/log/myapp.log
depend() {
need net
before caddy
}
start_pre() {
set -a
. /etc/myapp.env
set +a
export VIA_ADDR=127.0.0.1:8080
export VIA_ORIGIN=https://example.com
checkpath -f -o myapp:myapp -m 0600 /var/log/myapp.log
}Both run the binary as its own user, source /etc/myapp.env (mode 0600, one line: VIA_SESSION_KEY=…), and restart on exit after 2 s with no retry cap, so a crash loop keeps trying instead of parking the unit as failed. They mirror the files go-via.dev itself runs under; DEPLOY.md has the full hardening list and the first-install steps.
#What a process holds
| State | Where it lives | Survives restart? |
|---|---|---|
| Session cookie | The browser, signed with the session key | Only with a fixed key: via.WithSessionKey or VIA_SESSION_KEY |
| Session data | The via.SessionStore; by default a map in this process | Only with a shared store: via.WithSessionStore |
Tabs: streams, live units, via.State and via.List values | This process | No. The tab reloads and OnInit seeds it again |
via.Ctx.Tick timers, via.Ctx.Listen subscriptions, topics | This process | No. OnInit registers them again on the next connect |
#Rolling deploys
When a pod closes, each of its open tabs sees its stream end. via's client shows a Disconnected banner, polls the page URL with backoff from 500 ms to 8 s, and reloads once it answers. The reload is a fresh GET: OnInit runs again, via.State and signal values start from their seeds, and the session carries over if the store is shared. An action against a tab the server no longer knows answers 410, and the client reloads the same way.
Roll one pod at a time. Unsent client edits and any action in flight on the closing pod are lost; there is no replay.
#Sessions that survive a restart
// SQLSessions keeps sessions in one Postgres table:
//
// CREATE TABLE via_sessions (
// id text PRIMARY KEY,
// data bytea NOT NULL,
// expires timestamptz NOT NULL
// );
type SQLSessions struct{ DB *sql.DB }
func (s SQLSessions) Load(ctx context.Context, id string) ([]byte, bool, error) {
var data []byte
err := s.DB.QueryRowContext(ctx, `
SELECT data FROM via_sessions
WHERE id = $1 AND expires > now()`, id).Scan(&data)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
return data, err == nil, err
}
func (s SQLSessions) Save(ctx context.Context, id string, data []byte, ttl time.Duration) error {
_, err := s.DB.ExecContext(ctx, `
INSERT INTO via_sessions (id, data, expires) VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE
SET data = excluded.data, expires = excluded.expires`,
id, data, time.Now().Add(ttl))
return err
}
func (s SQLSessions) Delete(ctx context.Context, id string) error {
_, err := s.DB.ExecContext(ctx, `DELETE FROM via_sessions WHERE id = $1`, id)
return err
}
A via.SessionStore is three methods over opaque bytes. Against Redis they are GET, SET with an expiry, and DEL. Implement via.VersionedSessionStore as well if the backend can make a write conditional on the revision it read.
Rotate is a Save under the new id, then a Delete of the old. If the Save fails, or the old id can be neither deleted nor expired, via.Session.Rotate panics and the request answers 500 rather than report a rotation that did not happen. Expiry is the ttl handed to Save, and via stamps the same deadline into the blob and refuses an expired Load, so a backend with no TTL support is still correct; it only keeps dead rows until you delete them.
#Horizontal scaling
With a shared key and store, the session follows the user to any pod. The tab does not. An action POST carries a tab id and looks it up on the pod that opened the stream, so an action landing on another pod answers 410 and the tab reloads. A balancer needs affinity for the life of a tab to avoid that reload; correctness does not depend on it.
#State across pods
// Bus is the part of a pub/sub client the bridge needs.
type Bus interface {
Publish(ctx context.Context, subject string, payload []byte) error
Subscribe(ctx context.Context, subject string) (<-chan []byte, error)
}
type Room struct {
ID string
Bus Bus
Local *topic.Topic[Message]
}
// Bridge feeds what this pod hears on the bus into its local topic. Run one
// per room per pod, for the life of the process.
func (r *Room) Bridge(ctx context.Context) error {
in, err := r.Bus.Subscribe(ctx, "room."+r.ID)
if err != nil {
return err
}
for b := range in {
var m Message
if json.Unmarshal(b, &m) == nil {
r.Local.Publish(m)
}
}
return ctx.Err()
}
// Post publishes outward, never to r.Local: the sender's own pod hears it back
// through Bridge like every other pod does.
func (r *Room) Post(ctx context.Context, m Message) error {
b, err := json.Marshal(m)
if err != nil {
return err
}
return r.Bus.Publish(ctx, "room."+r.ID, b)
}
A topic.Topic fans out inside one process. Two pods are two brokers, and a publish on one never reaches a listener on the other; via has no bridge of its own. Invert the write path: handlers publish to your bus, and each pod runs one goroutine that feeds what it hears into the local topic.
type Chat struct {
Room *Room
Log via.List[Message]
}
func (c *Chat) OnInit(ctx *via.Ctx) error {
ctx.Listen(c.Room.Local, c.onMessage)
return nil
}
func (c *Chat) onMessage(ctx *via.Ctx, m Message) { c.Log.Append(m) }
Units keep listening to the local topic with via.Ctx.Listen or via.StateTrack and do not change. Bus is a few lines over go-redis (Publish(…).Err(), Subscribe(…).Channel()) or nats.go (Publish, ChanSubscribe).