On this page

Raw Markdown View this page on GitHub

Deployment

A deployment runs 1 or more copies of the server binary. Configure the binary in cmd/server/main.go, terminate TLS in the server or a reverse proxy, and collect its metrics and logs.

Build the binary

Build the Go server binary from cmd/server. It contains the compiled templates, the generated datapagesgen package and the app's embedded files. At run time, the scaffold also needs its environment variables and a NATS server.

Generate the code, then build:

templ generate
datapages gen
go build -o server ./cmd/server

The scaffold's .github/workflows/ci.yml runs both generators. When git keeps the generated code, the workflow fails when their output differs from the committed code. make build runs the last step and writes cmd/server/server. In a project set up with --generated-code hidden, make build runs both generators first. The scaffold's dependencies need no cgo. For a static Linux binary, run CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server.

Do not set DATAPAGES_DEV_MODE or TEMPL_DEV_MODE in production. Either variable turns on dev mode, in which the server reads static assets from the source directory and sends pages and assets uncompressed. The server logs dev mode is on at startup when either is set. See Dev mode.

Configure the server

datapages init writes cmd/server/main.go once and never regenerates it. Connect the broker and the session manager there, create the repositories and clients that App holds, and pass options to datapages.NewServer.

Environment variables

The scaffold's main.go reads these variables:

Variable Default Use
HOST localhost Address to listen on
PORT 8080 Port to listen on
NATS_URL required NATS server of the broker and the session manager
SESSION_ENCRYPTION_KEY required 16-byte key in hex that encrypts session cookies
HOST_METRICS localhost Address of the Prometheus metrics server
PORT_METRICS 9090 Port of the metrics server
PATH_TLS_CERT, PATH_TLS_KEY empty Certificate and key files, see Serve TLS
  • localhost accepts connections from the same machine only. Set HOST=0.0.0.0 in a container.
  • main first reads .env from the working directory and sets each variable that the environment leaves empty. datapages init writes .env with NATS_URL and a random SESSION_ENCRYPTION_KEY and adds the file to .gitignore. In a deployment, set the variables in the environment.
  • The process exits with status 2 when NATS_URL or SESSION_ENCRYPTION_KEY is unset, and with status 1 when another setup step fails. A listen error, such as a port in use, is logged and the process exits with status 0. A supervisor that restarts only failed processes does not restart it. The complete example exits with status 1 instead.
  • A main.go written for an app without a session type reads no SESSION_ENCRYPTION_KEY. datapages init --prometheus=false writes a main.go without the metrics variables.

Server options

datapages.NewServer applies its ServerOption values in order. The scaffold passes the access logger, WithSessions, WithPrometheus and the session manager, and WithAssets when the app declares static files. Append others to opts before the NewServer call.

Options of the process:

Option Default Sets
WithHTTPServer see HTTP server The net/http server with its timeouts and TLSConfig
WithShutdownTimeout 10s How long shutdown waits for requests and StreamClose hooks
WithBuildID hash of the executable The ID that makes pages of another build reload
WithLogger JSON on stderr, level INFO The logger of the server
WithLogSampling 1 of each warning per minute How often a repeated framework warning is logged
WithMiddleware none Handlers that wrap the router, in the order given
WithPrometheus none The metrics server, required by datapages.EnablePrometheus

Options for requests and responses:

Option Default Sets
WithBodySizeLimit 1 MiB The body limit of actions that take Signals
WithFileSendTimeout 1 minute How long a write of a datapages.File or an asset may block
WithResponseCompression zstd, brotli, gzip from 256 bytes Compression of page, action and error bodies
WithNoResponseCompression Turns that compression off
WithSSECompression zstd, brotli, gzip with 1 MiB windows Compression of streams
WithNoSSECompression Turns stream compression off

Options for static assets:

Option Default Sets
WithAssets none The embed.FS of the app's static files
WithAssetsFS none An http.FileSystem that replaces it
WithAssetsCache Cache-Control: public, max-age=0 and an ETag The cache headers of assets
WithAssetsCompression brotli 6 or gzip 9, kept in memory The asset compressor
WithNoAssetsCompression Turns asset compression off

Options for sessions and security:

Option Default Sets
WithSessionManager required with a session type The store of sessions
WithSessions cookie sessiontoken, Secure, HttpOnly The session cookie and the token generator
WithCSRFProtection on with sessions Other CSRF tokens, or protection off
WithCSPNonce none The Content-Security-Policy nonce of a request

Options for pages:

Option Default Sets
WithDatastarJS Datastar 1.0.4 from cdn.jsdelivr.net The URL of the Datastar bundle
WithStateConfig 10,000 instances The per-tab state limit of the server

The default level of WithLogger is DEBUG in dev mode. Declaring PageOffline generates a datapagesgen.WithOffline option. See Offline and shims.

HTTP server

Without WithHTTPServer, the server uses these values:

Field Value
ReadTimeout 30s
ReadHeaderTimeout 5s
WriteTimeout 0, no limit
IdleTimeout 60s
MaxHeaderBytes 1 MiB

ReadTimeout limits reading a request and its body. It does not end a stream that stays open longer. A request body that takes longer to arrive fails, which example/file-upload avoids by moving the read deadline while the bytes of an upload arrive.

WithHTTPServer replaces this server, values included. Datapages sets Addr, Handler and BaseContext, sets ErrorLog when it is nil and uses every other field as given. net/http reads a zero ReadTimeout or WriteTimeout as no limit, and a zero ReadHeaderTimeout or IdleTimeout as the ReadTimeout. Copy the values to keep from the generated package:

cmd/server/main.go
opts = append(opts, datapages.WithHTTPServer(&http.Server{
	ReadTimeout:       datapagesgen.DefaultHTTPReadTimeout,
	ReadHeaderTimeout: datapagesgen.DefaultHTTPReadHeaderTimeout,
	IdleTimeout:       datapagesgen.DefaultHTTPIdleTimeout,
	MaxHeaderBytes:    datapagesgen.DefaultHTTPMaxHeaderBytes,
	TLSConfig:         &tls.Config{MinVersion: tls.VersionTLS13},
}))

Keep WriteTimeout at 0. The write deadline counts from the request, not from the last event, and a stream fails at the first event it writes after WriteTimeout. The server logs an i/o timeout error and the page reconnects.

Choose modules

The broker and the session manager decide whether the app can run as more than 1 process:

Need 1 process Several processes
Events modules/messaging/inmem modules/messaging/natscore
Sessions modules/sessions/inmem modules/sessions/natskv

The scaffold uses the NATS modules. A restart of the server process loses:

  • messaging/inmem and messaging/natscore: nothing, because neither stores events. An event dispatched while a page has no open stream, as during a restart, never reaches that page. See Event delivery.
  • sessions/inmem: every session. The cookies then name no session, and the server clears them and treats every visitor as a guest.
  • sessions/natskv: nothing. Sessions live in the JetStream key-value bucket SESSIONS of the NATS server, which needs JetStream enabled. The scaffold's compose.yaml starts NATS with -js.
  • Per-tab state, with any modules: every instance. Each tab reconnects with zeroed state, and an action sent before the reconnect receives 409 and reloads the page. See Per-tab state.

For 1 process without NATS, replace the connectNATS() call of the scaffold with the in-memory modules:

cmd/server/main.go
messageBroker := inmem.New(0)
sessionManager := sessinmem.New[struct{}](
	sessions.DefaultTokenGenerator{Length: sessions.DefaultTokenLen},
)

The imports are github.com/romshark/datapages/modules/messaging/inmem and sessinmem "github.com/romshark/datapages/modules/sessions/inmem". inmem.New(0) buffers 16 events per stream. Use in-memory sessions only where signing every visitor out at each restart is acceptable. example/sqlitesessions implements a sessions.Manager on SQLite, which keeps the sessions of 1 process across restarts.

Datapages deletes an expired session when a handler reads it. Call DeleteExpired of the session manager on a ticker for the sessions that nobody sends again. See Sessions and auth.

Run several processes

Behind a load balancer:

  • Connect every process to the same NATS server with natscore and natskv. An event dispatched in 1 process then reaches the streams of every process, and every process reads the same sessions.
  • Give every process the same SESSION_ENCRYPTION_KEY. natskv encrypts the cookie with it, and a process with another key clears the cookie as stale, which signs the visitor out. CSRF tokens derive from the session token and need no shared secret.
  • Run the same binary in every process, or pass the same WithBuildID.
  • Send the stream and the actions of 1 tab to 1 process when a page uses per-tab state. The state lives in the process that serves the tab's stream. Route by a hash of the Datapages-Instance request header, by session or by an affinity cookie. Page loads carry no Datapages-Instance and may reach any process. An action that reaches another process receives 409 Conflict with Datapages-Retry: reconnect, and the page reloads.

Each process enforces its own WithStateConfig limit and serves its own metrics.

Deploy a new version

An open page keeps the document of the build that rendered it. Each document carries the build ID of its server in <meta name="datapages-build">, and an inline script sends it in the Datapages-Build header with every same-origin Datastar request. A server of another build answers 205 Reset Content without running the handler, and the script reloads the page:

  • after a random delay of up to 2s when the request was a stream reconnect,
  • at once when it was an action, which is not applied,
  • after 1s, 2s, 4s and up to 30s when reloads repeat without a successful request.

A reload discards signals, form input and focus.

The build ID defaults to the first 16 bytes of the SHA-256 hash of the executable, in hex. Any change to the binary changes it. The server logs it as build when it starts:

{"time":"2026-10-22T14:40:42.586217+02:00","level":"INFO","msg":"listening HTTP","addr":"127.0.0.1:18810","build":"4bbaa20b45ae57a327126137317d02bd"}

Set WithBuildID when the processes of 1 release run different binaries, such as builds for amd64 and arm64. Give every release its own ID, such as the commit:

cmd/server/main.go
// buildID is set with -ldflags "-X main.buildID=<release>".
// Empty keeps the default, a hash of the executable.
var buildID string
cmd/server/main.go
if buildID != "" {
	opts = append(opts, datapages.WithBuildID(buildID))
}
go build -ldflags "-X main.buildID=$(git rev-parse --short HEAD)" -o server ./cmd/server

An ID has 1 to 128 printable ASCII characters without spaces. Every document contains it, which makes it public.

During a rolling deployment, old and new processes serve at the same time. Route each client to 1 build until the old processes stop, for example with session affinity. A tab whose requests alternate between builds reloads at every switch.

Shutdown resets every open stream. Datastar reconnects after 1s and doubles the delay up to 30s, without a limit, also after the 502 of a proxy while no process runs. A stream that reaches the same build continues, and a stream that reaches another build reloads its page. See Deployments.

Shut down gracefully

The scaffold cancels the context of ListenAndServe on SIGINT and SIGTERM:

cmd/server/main.go
ctx, cancel := signal.NotifyContext(context.Background(),
	os.Interrupt, syscall.SIGTERM)
defer cancel()

Docker, Kubernetes and systemd stop a process with SIGTERM. Without the signal context, SIGTERM ends the process without a shutdown. When the context ends, the server:

  1. logs server shutdown initiated and stops accepting connections, also on the metrics server,
  2. runs the StreamClose hook of each open stream and resets its connection, which makes the page reconnect,
  3. waits for the requests in progress,
  4. returns nil from ListenAndServe.

The wait ends after 10s by default. Set another limit:

cmd/server/main.go
opts = append(opts, datapages.WithShutdownTimeout(20*time.Second))

When the limit passes, the server logs shutting down with the error context deadline exceeded and ListenAndServe returns. Requests still running end when the process exits. Keep the limit below the time the platform waits before it kills the process, such as the 30s default of Kubernetes terminationGracePeriodSeconds and the 10s default of docker stop.

Middleware added with WithMiddleware that recovers panics must re-panic http.ErrAbortHandler. Shutdown resets streams with that panic.

Serve TLS

In the process, set PATH_TLS_CERT and PATH_TLS_KEY. The scaffold then calls ListenAndServeTLS(ctx, addr, certFile, keyFile), which loads the files once at startup. To renew certificates without a restart, set TLSConfig.GetCertificate through WithHTTPServer and change cmd/server/main.go to call ListenAndServeTLS(ctx, addr, "", ""). With empty file names, ListenAndServeTLS takes the certificates from the TLSConfig. The scaffold calls ListenAndServe when both variables are empty, which serves plain HTTP. Set the minimum TLS version and the other TLS settings in the same TLSConfig.

In front of the process, a reverse proxy or a load balancer terminates TLS and forwards plain HTTP. The session cookie keeps its Secure attribute, which is correct because the browser talks HTTPS. Set SessionsConfig.DisableSecureCookie only when the whole deployment uses plain HTTP.

Serve browsers over HTTP/2. Over HTTP/1.1, a browser opens at most 6 connections to 1 host, and each open stream holds 1 of them. Over HTTP/2, the streams and requests of a browser share 1 connection. ListenAndServeTLS negotiates HTTP/2. Behind a proxy, enable HTTP/2 between the browser and the proxy.

The offline service worker runs only on HTTPS or localhost. See Use TLS for HSTS and the cookie attributes.

Run behind a reverse proxy

Each open page with a stream holds 1 request open, a GET of the page path followed by _$/, answered with text/event-stream. The server writes each event when it happens and nothing between events. The proxy must:

  • Pass each event on at once. nginx buffers proxied responses unless proxy_buffering off applies or the response carries X-Accel-Buffering: no. The middleware below adds the header to streams.
  • Keep quiet streams open. A proxy that closes a connection after a time without data, as nginx does after proxy_read_timeout (60s by default), ends every stream that has no event for that long. The page reconnects after 1s, but it misses the events dispatched in between and its per-tab state starts zeroed. Set the timeout above the longest quiet time of a page, such as 1 hour.
  • Forward the Host header of the browser. For browsers that send no Sec-Fetch-Site, the cross-origin check of form actions compares Origin with Host and answers 403 when they differ.
  • Forward the headers that Datapages reads and writes, Datastar-Request, Datapages-Build, Datapages-Instance and X-CSRF-Token in requests and Datapages-Build, Datapages-Instance and Datapages-Retry in responses.
  • Send the stream and the actions of 1 tab to 1 process when pages use per-tab state, see Run several processes.
cmd/server/main.go
// streamsUnbuffered asks nginx to pass the events of each stream at once.
func streamsUnbuffered(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.Contains(r.URL.Path, "/_$/") {
			w.Header().Set("X-Accel-Buffering", "no")
		}
		next.ServeHTTP(w, r)
	})
}

Add it with datapages.WithMiddleware(streamsUnbuffered).

Datapages compresses pages, action responses, streams and assets itself and sends Vary: Accept-Encoding. A compressing proxy passes these responses on unchanged. A proxy that edits bodies, such as nginx with sub_filter, needs them uncompressed. See Choose where compression runs.

Collect Prometheus metrics

The third type argument of NewServer decides whether the server counts metrics. datapages.EnablePrometheus, which the scaffold uses, requires WithPrometheus, and datapages.DisablePrometheus rejects it. datapages gen reads the type argument, and a change takes effect at the next datapages gen. datapages init --prometheus=false writes DisablePrometheus.

WithPrometheus starts a second HTTP server, which serves /metrics and answers 404 to other paths. It starts and stops with ListenAndServe. The scaffold binds it to HOST_METRICS and PORT_METRICS, localhost:9090 by default. Keep it out of reach of the public network.

PrometheusConfig.Collectors registers metrics of the application:

cmd/server/main.go
var ordersPlaced = prometheus.NewCounter(prometheus.CounterOpts{
	Name: "shop_orders_placed_total",
	Help: "Orders placed.",
})
cmd/server/main.go
datapages.WithPrometheus(datapages.PrometheusConfig{
	Host:       "localhost:9090",
	Collectors: []prometheus.Collector{ordersPlaced},
})

Registerer and Gatherer default to the default registry of github.com/prometheus/client_golang, which also exports Go runtime and process metrics such as go_goroutines. Handler replaces the handler of /metrics. Datapages exports:

Metric Type Labels Counts
datapages_http_requests_total counter method, path, status Requests, streams included
datapages_http_request_duration_seconds histogram method, path Request duration, streams excluded
datapages_http_in_flight_requests gauge Requests in progress, streams excluded
datapages_http_sse_connections gauge Open streams
datapages_sse_connection_duration_seconds histogram Stream lifetime
datapages_sse_disconnects_total counter reason Stream ends by close, expired, client or shutdown
datapages_event_broker_publishes_by_kind_total counter kind Dispatched events
datapages_event_broker_deliveries_dropped_total counter Events dropped for a full stream buffer
datapages_internal_errors_recovered_total counter Errors that RecoverError answered
datapages_internal_errors_not_recovered_total counter Errors that RecoverError did not handle
datapages_action_options_dropped_total counter option Action options dropped for an invalid value
datapages_session_creations_total counter result Sessions created, by success or error
datapages_session_closures_total counter result Sessions closed, by success or error
datapages_session_reads_total counter result Cookie reads, by valid, none, stale, expired or error
datapages_state_instances gauge Live per-tab state instances

path holds the route pattern, such as GET /account/{$}, not the URL, which keeps the number of series bounded. A URL that no other route matches counts under GET / of PageIndex. A request that matches no route at all counts as <unmatched>, and a method outside the standard ones as <other>. kind is the subject that the event type declares, such as messaging.sent. The reason values close and expired count the streams that the close or the expiry of their session ended.

Log requests

WithLogger sets the logger of the server. The default writes JSON to stderr at level INFO, and at DEBUG in dev mode. At INFO, the server logs its start with addr and build, the start of a shutdown and errors. At DEBUG, it adds the cause of each 400 response and the requests of pages from another build.

The scaffold's withAccessLogger adds a middleware that logs the method and path of each request at INFO before the handler runs. To log the status and the duration, wrap the http.ResponseWriter and give the wrapper an Unwrap method. Streams flush through http.ResponseController, which reaches the writer underneath only through Unwrap. Without it every stream fails.

cmd/server/main.go
// statusRecorder records the status code of a response.
type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (s *statusRecorder) WriteHeader(code int) {
	if s.status == 0 {
		s.status = code
	}
	s.ResponseWriter.WriteHeader(code)
}

// Unwrap lets http.ResponseController reach the Flush of the writer
// underneath, which every stream calls.
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }

func withAccessLogger(opts *[]datapages.ServerOption) {
	logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	}))
	*opts = append(*opts, datapages.WithMiddleware(func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			rec := &statusRecorder{ResponseWriter: w}
			next.ServeHTTP(rec, r)
			if rec.status == 0 {
				rec.status = http.StatusOK
			}
			logger.Info("access",
				slog.String("method", r.Method),
				slog.String("path", r.URL.Path),
				slog.Int("status", rec.status),
				slog.Duration("duration", time.Since(start)))
		})
	}))
}

The line of a stream appears when the stream ends, with its lifetime as the duration.

WithLogSampling throttles the warnings about action options dropped for an invalid value, URLs that href.External cannot use and assets that the compressor cannot read or compress. Datapages writes them on every render or request while their cause persists. By default it logs 1 of each per minute and adds the number held back as suppressed. It does not touch the logs of the application or the other logs of the server.

Complete example

This cmd/server/main.go combines the settings above for an app in the module example.com/shop with struct{} session data. It leaves out the .env loading and the access logger of the scaffold, accepts previous session keys for key rotation and exits with status 1 when the server fails.

cmd/server/main.go
package main

import (
	"context"
	"encoding/hex"
	"errors"
	"fmt"
	"log/slog"
	"net"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

	"github.com/nats-io/nats.go"
	"github.com/romshark/datapages"
	"github.com/romshark/datapages/modules/messaging/natscore"
	"github.com/romshark/datapages/modules/sessions"
	"github.com/romshark/datapages/modules/sessions/natskv"

	"example.com/shop/app"
	"example.com/shop/app/datapagesgen"
)

// buildID is set with -ldflags "-X main.buildID=<release>".
// Empty keeps the default, a hash of the executable.
var buildID string

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
	ctx, cancel := signal.NotifyContext(context.Background(),
		os.Interrupt, syscall.SIGTERM)
	defer cancel()
	if err := run(ctx, logger); err != nil {
		logger.Error("running server", slog.Any("err", err))
		os.Exit(1)
	}
}

func run(ctx context.Context, logger *slog.Logger) error {
	natsURL := os.Getenv("NATS_URL")
	if natsURL == "" {
		return errors.New("NATS_URL not set")
	}
	conn, err := nats.Connect(natsURL)
	if err != nil {
		return fmt.Errorf("connecting to NATS: %w", err)
	}
	defer conn.Close()

	sessionManager, err := newSessionManager(conn)
	if err != nil {
		return err
	}

	opts := []datapages.ServerOption{
		datapages.WithLogger(logger),
		datapages.WithSessionManager[struct{}](sessionManager),
		datapages.WithShutdownTimeout(20 * time.Second),
		datapages.WithPrometheus(datapages.PrometheusConfig{
			Host: net.JoinHostPort(
				envOr("HOST_METRICS", "localhost"), envOr("PORT_METRICS", "9090"),
			),
		}),
	}
	if buildID != "" {
		opts = append(opts, datapages.WithBuildID(buildID))
	}

	s, err := datapages.NewServer[
		app.App,
		struct{},
		datapages.EnablePrometheus,
		datapagesgen.Server,
	](&app.App{}, natscore.New(conn, natscore.Config{}), opts...)
	if err != nil {
		return fmt.Errorf("creating server: %w", err)
	}

	addr := net.JoinHostPort(envOr("HOST", "localhost"), envOr("PORT", "8080"))
	cert, key := os.Getenv("PATH_TLS_CERT"), os.Getenv("PATH_TLS_KEY")
	if cert == "" && key == "" {
		return s.ListenAndServe(ctx, addr)
	}
	return s.ListenAndServeTLS(ctx, addr, cert, key)
}

// newSessionManager encrypts new session cookies with SESSION_ENCRYPTION_KEY.
// It also decrypts cookies under the keys in SESSION_PREVIOUS_ENCRYPTION_KEYS,
// separated by commas. Each key is 16 bytes in hex.
func newSessionManager(conn *nats.Conn) (*natskv.SessionManager[struct{}], error) {
	key, err := hex.DecodeString(os.Getenv("SESSION_ENCRYPTION_KEY"))
	if err != nil {
		return nil, fmt.Errorf("decoding SESSION_ENCRYPTION_KEY: %w", err)
	}
	var previous [][]byte
	for _, h := range strings.Split(os.Getenv("SESSION_PREVIOUS_ENCRYPTION_KEYS"), ",") {
		if h == "" {
			continue
		}
		k, err := hex.DecodeString(h)
		if err != nil {
			return nil, fmt.Errorf("decoding SESSION_PREVIOUS_ENCRYPTION_KEYS: %w", err)
		}
		previous = append(previous, k)
	}
	return natskv.New[struct{}](
		conn,
		sessions.DefaultTokenGenerator{Length: sessions.DefaultTokenLen},
		natskv.Config{EncryptionKey: key, PreviousEncryptionKeys: previous},
	)
}

func envOr(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

See also

  • Deployments: the build ID, the reload rules and stream reconnects.
  • App: graceful shutdown and error recovery.
  • Per-tab state: what lives in 1 process and how to route it.
  • Security: TLS, session keys, the Content-Security-Policy and the limits to put in front of the server.
  • Performance: compression settings and their costs.
  • ServerOption: the documentation of every option.