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
| 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 is set only when the request itself came over TLS. Behind a proxy that terminates TLS, never. |
| Stream cap | via.WithMaxSSEConn | 10000 streams per router; the next connect answers 503. |
| 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
}
}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
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.
}
| 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 for its tab's goroutine before answering 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.
#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)
r.Close()
shut, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shut)
Fail readiness, then wait
Long enough for the balancer to mark the pod down: probe interval times failure threshold.
Close the router
via.Router.Closeends 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; a connect after Close answers 503. Calling it twice is fine.Shut the server down
http.Server.Shutdowndrains 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
[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, 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).