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 |
localhostaccepts connections from the same machine only. SetHOST=0.0.0.0in a container.mainfirst reads.envfrom the working directory and sets each variable that the environment leaves empty.datapages initwrites.envwithNATS_URLand a randomSESSION_ENCRYPTION_KEYand adds the file to.gitignore. In a deployment, set the variables in the environment.- The process exits with status 2 when
NATS_URLorSESSION_ENCRYPTION_KEYis 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.gowritten for an app without a session type reads noSESSION_ENCRYPTION_KEY.datapages init --prometheus=falsewrites amain.gowithout 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:
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/inmemandmessaging/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 bucketSESSIONSof the NATS server, which needs JetStream enabled. The scaffold'scompose.yamlstarts 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:
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
natscoreandnatskv. 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.natskvencrypts 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-Instancerequest header, by session or by an affinity cookie. Page loads carry noDatapages-Instanceand may reach any process. An action that reaches another process receives409 ConflictwithDatapages-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:
// buildID is set with -ldflags "-X main.buildID=<release>".
// Empty keeps the default, a hash of the executable.
var buildID string
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:
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:
- logs
server shutdown initiatedand stops accepting connections, also on the metrics server, - runs the
StreamClosehook of each open stream and resets its connection, which makes the page reconnect, - waits for the requests in progress,
- returns nil from
ListenAndServe.
The wait ends after 10s by default. Set another limit:
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 offapplies or the response carriesX-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
Hostheader of the browser. For browsers that send noSec-Fetch-Site, the cross-origin check of form actions comparesOriginwithHostand answers 403 when they differ. - Forward the headers that Datapages reads and writes,
Datastar-Request,Datapages-Build,Datapages-InstanceandX-CSRF-Tokenin requests andDatapages-Build,Datapages-InstanceandDatapages-Retryin responses. - Send the stream and the actions of 1 tab to 1 process when pages use per-tab state, see Run several processes.
// 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:
var ordersPlaced = prometheus.NewCounter(prometheus.CounterOpts{
Name: "shop_orders_placed_total",
Help: "Orders placed.",
})
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.
// 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.
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.