via

You are reading the docs for v0.8. Read the latest (v0.10)

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 is set only when the request itself came over TLS. Behind a proxy that terminates TLS, never.
Stream capvia.WithMaxSSEConn10000 streams per router; the next connect answers 503.
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
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 / {
		proxy_pass http://127.0.0.1:8080;
		proxy_http_version 1.1;
		proxy_set_header Connection "";
		proxy_set_header Host $host;
		# 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: without a trusted origin match, via's same-origin check compares the browser's Origin with it.

#Timeouts

main.go
srv := &http.Server{
	Addr:              os.Getenv("VIA_ADDR"),
	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 for its tab's goroutine before answering 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.

#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)
r.Close()
shut, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shut)
  1. Fail readiness, then wait

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

  2. Close the router

    via.Router.Close 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; a connect after Close answers 503. Calling it twice is fine.

  3. Shut the server down

    http.Server.Shutdown drains the plain requests. It does not cancel the router's streams: called first, it waits on tabs that never end.

Five seconds of drain and five of Shutdown 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, func(_ *via.Ctx, m Message) { c.Log.Append(m) })
	return nil
}

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