via

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

SettingSet it withLeft unset
Session keyVIA_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 storevia.WithSessionStoreA map in this process. A restart logs everyone out even with a fixed key.
Trusted originvia.WithTrustedOriginFails open: actions accept requests from every origin, including ones that carry no origin signal at all. via logs one warning at startup.
Secure cookiesvia.WithSecureCookiesSecure follows TLS, or the proxy's X-Forwarded-Proto or Forwarded header. Unneeded behind Caddy, or nginx configured as below.
Stream capvia.WithMaxSSEConn10000 streams per router; the next connect answers 503.
Per-client limitsThe proxy, belowOne client can open streams until the router-wide cap refuses everyone.
Idle timeoutsProxy and balancer, above 25 s; via.WithPinnedDeadline below the balancer's request timeoutThe proxy cuts idle streams, or answers a stuck action before via can.
Readiness probeYour own handler, belowThe balancer keeps sending new tabs to a pod that is shutting down.
main.go
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

Caddyfile
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
	}
}
nginx.conf
# 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

main.go
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.
}
TimerValueWhat it means for you
Keepalive frame25 s, fixedThe longest a healthy stream stays silent. Every proxy and balancer idle timeout on the path must be longer: 60 s is enough.
Frame write10 s, fixedA peer that stops reading loses its stream after this.
Pinned deadline5 s, via.WithPinnedDeadlineHow 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.WriteTimeout0It 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

main.go
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

main.go
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))
  1. Fail readiness, then wait

    Long enough for the balancer to mark the pod down: probe interval times failure threshold.

  2. Shut the router down

    via.Router.Shutdown ends every stream the way a closed tab ends, with a clean end of response, and runs each via.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.Close is Shutdown with no deadline.

  3. Shut the server down

    http.Server.Shutdown drains 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

/etc/systemd/system/myapp.service
[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
/etc/init.d/myapp
#!/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

StateWhere it livesSurvives restart?
Session cookieThe browser, signed with the session keyOnly with a fixed key: via.WithSessionKey or VIA_SESSION_KEY
Session dataThe via.SessionStore; by default a map in this processOnly with a shared store: via.WithSessionStore
Tabs: streams, live units, via.State and via.List valuesThis processNo. The tab reloads and OnInit seeds it again
via.Ctx.Tick timers, via.Ctx.Listen subscriptions, topicsThis processNo. 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

store.go
// 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

bridge.go
// 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)
}
bridge.go
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.

bridge.go
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).