Guía 17
Deployment
Esta página aún no está traducida, así que está en inglés. Tradúcela en GitHub
En esta página
A tug app ships as one binary: the Go server, with the frontend's build
inside it. tug build makes that binary, and every app that tug new makes
has a Dockerfile that builds it into an image. The binary takes its
settings from the environment. This page covers each of those, then
HTTPS served by the app itself, running behind a proxy, shutting down,
health checks, rotating the key, and logs.
tug build, run in the app's directory, says what it's doing, among the
output of npm and Vite:
tug build: the types
tug build: type-checking the frontend
tug build: the frontend
tug build: the binary
tug build: blog, 9.6 MB, frontend includedIn order, it:
- writes the TypeScript types, as
tug gendoes, by building the app and running it: see typescript.md; - runs the
typecheckscript whenpackage.jsonhas one, as the starters' does (tsc --noEmit), so a frontend that no longer fits the Go stops the build; - runs the
buildscript,vite build, which writes the frontend topublic/build; - builds the Go with
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w": a static binary without its symbol table and debug information, named after the app's directory, or as-osays.
The scripts run with the package manager that the app's lockfile is for:
bun, pnpm or yarn, and npm when there's none of theirs. Like tug dev,
tug build reads the app's .env for these steps.
The binary is the whole app:
APP_KEY=base64:... ./blogIt doesn't read .env, though: only tug dev, tug gen and tug build
do. Set its variables in the environment it runs in, which the
configuration lists.
tug build makes a binary for the machine it runs on. Setting GOOS for
it fails, as it runs the app it builds to write the types:
tug: the app stopped before writing its types (fork/exec .../.tug/app: exec format error):For a Linux server, from a Mac, let tug build build the frontend, then
build the Go again for Linux, which embeds the same public/build:
tug build
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o blog-linux .The Dockerfile is the other way: it builds on Linux.
The starter's main.go embeds public/, and serves the build from it:
// public is what the frontend's build writes to public/build, and anything
// else public/ holds. The .gitkeep lets it compile before the first build.
//
//go:embed all:public
var public embed.FS
// in main and newApp
build, err := fs.Sub(public, "public/build")
assets, err := vite.New(vite.Config{Build: build, HotFile: "public/hot"})
app.Get("/build/{path...}", tug.WrapHandler(assets))all:embeds names that start with.or_, whichgo:embedleaves out otherwise, and Vite writes its manifest to.vite/manifest.json.go buildembeds whatpublic/buildholds when it runs, which is whytug buildbuilds the frontend first. A binary built before any frontend build answers every page with a 500, and the log says there's no build to load.- The build is served under
/build/. The files inassets/have a hash of their content in their names, so they're cached for a year:Cache-Control: public, max-age=31536000, immutable. The manifest, and anything else whose name starts with a dot, isn't served. - Its scripts and styles go gzipped to a browser that takes it: see Compression.
- The manifest's hash is the frontend's version, which Inertia's client sends with each visit. After a deploy with a new frontend, a browser still running the old one gets a 409 on its next visit and loads the page afresh, so open tabs pick up the deploy.
Run in the app's directory, the binary finds the public/hot that Vite's
dev server writes, and loads the frontend from the dev server rather than
from its own build. A dev server that didn't exit cleanly leaves the file
behind: delete it.
| Variable | What it does | Default | Read by |
|---|---|---|---|
ADDR |
Where the app listens, as host:port: with HTTPS of its own, where it serves HTTPS. |
:8080, every interface, or with HTTPS of its own, :443 |
tug.ConfigFromEnv |
PORT |
The port, when ADDR isn't set, as Cloud Run and Fly.io set it. |
none | tug.ConfigFromEnv |
APP_DEBUG |
true or 1 shows a server error with its details: the error, a panic's stack with the app's source around it, and the request (While debugging). Leave it off in production. |
off | tug.ConfigFromEnv |
APP_EDITOR |
For development: the editor APP_DEBUG's page opens a frame's file in, as vscode. |
none | tug.ConfigFromEnv |
APP_URL |
The app's address, such as https://example.com, which the links that leave it start with: AbsoluteURL and SignedURL's, and the auth starter's mail, whose passkeys are for it too. With https://, the starters' session cookie is for HTTPS only, and their pages hold the browser to HTTPS (Security headers). tug dev sets it to the address it shows. |
none: the auth starter stops without it | tug.ConfigFromEnv |
TLS_DOMAINS |
The domains the app serves HTTPS for itself, comma separated, as example.com,www.example.com, with a certificate for each from Let's Encrypt, whose terms setting it accepts: see HTTPS on its own. APP_URL's host is one of them. |
none: plain HTTP, for TLS ended in front of the app | letsencrypt.FromEnv, in the starters' main.go |
CERTS_PATH |
Where the certificates from Let's Encrypt, and the key of the app's account with it, are kept. | certs, and /data/certs in the images |
letsencrypt.FromEnv |
ACME_URL |
A CA to get them from other than Let's Encrypt, as its staging, https://acme-staging-v02.api.letsencrypt.org/directory, to try a deploy without spending its limits. |
Let's Encrypt | letsencrypt.FromEnv |
TLS_CERT, TLS_KEY |
A certificate of the app's own, with its chain, and its key, PEM files, to serve HTTPS with in place of Let's Encrypt's, read again as they change. | none | tug.ConfigFromEnv |
HTTP_ADDR |
Where the app serves plain HTTP beside its HTTPS, which sends each request to HTTPS. | :80 |
tug.ConfigFromEnv |
TUG_DEV |
Set by tug dev, for development: the app keeps an entry of each request for Inertia's DevTools (pages.md), and the mail it sends with no MAIL_HOST in its mailbox, at /_tug/mail, and an app with server-side rendering leaves it to Vite. Leave it unset in production. |
unset | tug.ConfigFromEnv, and the starters' main.go with -ssr |
APP_KEY |
Encrypts the session cookies. The auth starter also encrypts two-factor secrets with it, and signs the links in its mail and to its photos, as SignedURL signs. |
none: the starters stop without it | session.KeysFromEnv |
APP_PREVIOUS_KEYS |
Keys being rotated out, comma separated. They still decrypt sessions and check links. | none | session.KeysFromEnv |
TRUSTED_PROXIES |
The proxies in front of the app, such as a load balancer, whose X-Forwarded-For says whose each request is: addresses or ranges, comma separated, as 10.0.0.0/8, or * for whatever connects. See Behind a proxy. |
none: a request is from whatever connected | the starters' main.go, for middleware.TrustProxies |
CORS_ORIGINS |
The sites whose pages may call the app's API, /api, from the browser, with a user's API token: origins, comma separated, as https://app.example.com, or * for any. |
none: a script or a phone's app, which isn't a page, needs none | the auth starter's main.go, for middleware.CORS |
The auth starter reads these as well:
| Variable | What it does | Default | Read by |
|---|---|---|---|
DB_PATH |
The SQLite database. | app.db, and /data/app.db in its image |
its db.go |
DB_URL |
On Postgres or MySQL, the database, as postgres://user:password@host:5432/blog?sslmode=require or mysql://user:password@host:3306/blog?tls=true, with the driver's settings in its query. |
none: needed, unless DB_HOST is set |
its db.go |
DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD |
The database's parts, as Laravel names them, where there's no DB_URL. |
the port is 5432 or 3306 |
its db.go |
SESSION_STORE |
Where the sessions themselves are kept: cookie, in the cookies, or database, in the app's sessions table, for sessions larger than the 4 KB a cookie takes (Kept on the server). Moving to the table logs nobody out; moving back logs everybody out. |
cookie |
its main.go |
FILES_PATH |
The directory the photos people upload are kept in, unless FILESYSTEM_DISK is s3. |
files, and /data/files in its image |
its main.go |
FILESYSTEM_DISK |
local, the directory, or s3, a bucket, which instances on more than one machine share. |
local |
storage.FromEnv |
AWS_BUCKET, AWS_DEFAULT_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT, AWS_USE_PATH_STYLE_ENDPOINT |
The bucket, and the keys to it, for FILESYSTEM_DISK=s3: in S3, or a service that speaks its API, as files.md has. |
none, and us-east-1 for the region |
storage.FromEnv |
QUEUE_WORKERS |
How many background jobs, such as mail, run at once. With 0, this instance runs none and leaves its jobs to the others on the database. |
4 |
its main.go |
MAIL_HOST |
The SMTP server. Without it, mail is written to the standard error instead of sent, or under tug dev kept in the app's mailbox. |
none | mail.FromEnv |
MAIL_PORT |
The server's port. On 465 the connection is TLS from the start; on another, it uses STARTTLS when the server offers it. | 587 |
mail.FromEnv |
MAIL_USERNAME, MAIL_PASSWORD |
The login, for a server that wants one. | none | mail.FromEnv |
MAIL_FROM_ADDRESS |
Who mail is from. It's needed with MAIL_HOST. |
none | mail.FromEnv |
MAIL_FROM_NAME |
The name that goes with it. | none | mail.FromEnv |
A key is 32 random bytes in base64, after base64:, which tug key
prints:
tug keyThe .env that tug new writes has a key for development. A deployed app
needs a key of its own, kept secret, and the same one in every copy of the
app that runs: a session one copy writes, another reads.
Encryption has what the key encrypts and signs, and how
to rotate it.
ADDR wins over PORT. The Dockerfiles set neither, so the app listens on
:8080, or on the PORT that a platform such as Cloud Run sets, or with
TLS_DOMAINS, on :443 and :80.
An app with server-side rendering, tug new -ssr, reads these as well
(ssr.md):
| Variable | What it does | Default | Read by |
|---|---|---|---|
SSR_NODE |
The Node that renders pages on the server, beside the app. | the node on PATH, and /nodejs/bin/node in its image |
its main.go |
SSR_URL |
An SSR server run apart, such as http://127.0.0.1:13714, to render pages with, in place of a Node of the app's own. |
none | its main.go |
The plain starter's:
# The app as one static binary, frontend included, on an image with nothing
# else in it.
#
# docker build -t app . && docker run -p 8080:8080 -e APP_KEY=... app
#
# Or serving HTTPS itself, on a machine of its own, with a certificate from
# Let's Encrypt for each of TLS_DOMAINS, kept on /data, a volume, so a new
# container doesn't ask again:
#
# docker run -p 443:443 -p 80:80 -v app-data:/data -e APP_KEY=... \
# -e APP_URL=https://example.com -e TLS_DOMAINS=example.com app
FROM node:24-alpine AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM golang:1.26-alpine AS server
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend /app/public/build public/build
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /server . && mkdir /data
FROM gcr.io/distroless/static-debian12
COPY --from=server /server /server
# 65532 is the image's nonroot user, who has to be able to write the
# certificates.
COPY --from=server --chown=65532:65532 /data /data
ENV CERTS_PATH=/data/certs
# The app listens on :8080, or on the PORT a platform sets; or with
# TLS_DOMAINS, or TLS_CERT and TLS_KEY, on :443 and :80.
EXPOSE 8080 443 80
VOLUME /data
USER nonroot:nonroot
ENTRYPOINT ["/server"]frontendinstalls the npm packages and runsvite build. It uses npm andpackage-lock.json, astug newsets an app up. It has no Go, so it neither writes the types nor checks them: it builds with the files inresources/js/tugas they are, which is why they're committed (see typescript.md).serverdownloads the Go modules in a layer of their own, which Docker keeps until go.mod or go.sum changes, puts the frontend's build inpublic/build, and builds the binary astug builddoes.- The image the app runs in is distroless: the binary and not much else,
with no shell and no package manager. It has CA certificates, which mail
over TLS needs, and time zone data. The app runs as its
nonrootuser. /datais a volume, which keeps the certificates of an app that serves HTTPS itself, from Let's Encrypt, so that a new container doesn't ask for them again: see HTTPS on its own.
.dockerignore keeps node_modules, public/build, public/hot,
.tug, .env, certs and .git out of the build, and the auth starter's on
SQLite keeps a development app.db out too. So the frontend is built
afresh, and the development .env never reaches the image: the
variables come with docker run -e, as the Dockerfile's comment shows.
The auth starter's image, on SQLite, keeps its database, the photos
people upload, and its certificates, in /data, a volume, so that they
outlive the container:
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /server . && mkdir /data
FROM gcr.io/distroless/static-debian12
COPY --from=server /server /server
# 65532 is the image's nonroot user, who has to be able to write the database.
COPY --from=server --chown=65532:65532 /data /data
ENV DB_PATH=/data/app.db FILES_PATH=/data/files CERTS_PATH=/data/certs
# The app listens on :8080, or on the PORT a platform sets; or with
# TLS_DOMAINS, or TLS_CERT and TLS_KEY, on :443 and :80.
EXPOSE 8080 443 80
VOLUME /data
USER nonroot:nonroot
ENTRYPOINT ["/server"]The app runs as nonroot, whose ID is 65532, so /data has to belong to
65532. Owning the file isn't enough: SQLite makes the database in that
directory when it isn't there, and, with the write-ahead log the starter
turns on, its -wal and -shm files beside it, and the app makes
/data/files as the first photo goes in. With FILESYSTEM_DISK=s3, the
photos are in the bucket instead, and the volume has the database, and
the certificates of an app that serves HTTPS itself.
The distroless image has no shell to make the directory in, so the
server stage makes it and the copy sets its owner. A new named volume
starts with the image's /data, owner and all. A directory of the
host's, mounted with -v /srv/blog:/data, keeps the host's owner: make
it writable by 65532.
The SQLite driver is modernc.org/sqlite, in pure Go, so the binary is still static.
An app made with -postgres or -mysql has the same image, with /data
for the photos and the certificates alone: the database is wherever DB_URL says, with the
driver's settings in its query, such as Postgres's sslmode:
docker run -p 8080:8080 -v blog-data:/data \
-e APP_KEY=base64:... -e APP_URL=https://example.com \
-e DB_URL='postgres://blog:...@db.example.com:5432/blog?sslmode=require' \
-e MAIL_HOST=smtp.example.com -e MAIL_FROM_ADDRESS=hello@example.com blogor DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME and DB_PASSWORD,
as Laravel has them. The drivers, pgx and go-sql-driver/mysql, are pure
Go too. The app runs the migrations it hasn't as it starts, and instances
starting at once take turns; a release step can run them first, ./blog migrate, so one that fails stops the deploy before an instance of the
new version serves (Migrations).
With the photos in a bucket as well, FILESYSTEM_DISK=s3, an instance
keeps nothing of its own, and as many as the database takes can run
side by side, anywhere that reaches it: the jobs, the throttles' counts
of the tries at logging in, the cache and its locks, and the sessions, in
their cookies, are the same whichever instance a request reaches, and a
page hears the events of every instance
(Broadcasting). On Postgres, each instance holds one
connection of its own for them, which LISTENs; on MySQL, each reads
their table four times a second. The starter is tested on Postgres 18
and MySQL 8.4.
The app's compose.yaml is for development: it runs the database on
127.0.0.1, with a password everyone knows. A deployed app's database is
a managed one's, or one run apart from the app.
With server-side rendering, the frontend stage's npm run build writes the
SSR build to ssr/build too, which the Go stage copies in, to embed, and
the image is gcr.io/distroless/nodejs24-debian12, which has Node, at
/nodejs/bin/node, as SSR_NODE says. The binary is still static; Node is
there for it to run. Running the image:
docker run -p 8080:8080 -v blog-data:/data \
-e APP_KEY=base64:... -e APP_URL=https://example.com \
-e MAIL_HOST=smtp.example.com -e MAIL_USERNAME=... -e MAIL_PASSWORD=... \
-e MAIL_FROM_ADDRESS=hello@example.com blogThe app sends the frontend's build gzipped itself, as it has no web server in front of it to: each script and style, and the rest of the build that compresses, gzipped the first time it's asked for, and kept, to a browser that takes gzip. The auth starter's build of 640 KB goes as 205 KB. pages.md has the rest, and how to send brotli.
Pages and JSON go as they are. Compressing a response that holds a secret beside text an attacker can have it say tells the secret by the response's size, which is the BREACH attack. tug's CSRF puts no token in a page, but a page's props can hold what's private, as the auth starter's new API token, and only the app knows which do. A proxy or CDN in front, as Cloudflare or a load balancer, compresses them when the app chooses it; the build has nothing secret, and changes only with a deploy.
On a machine of its own, with no proxy in front to end TLS, the app
serves HTTPS itself, with certificates from Let's Encrypt. Point each
domain's DNS at the machine, and set TLS_DOMAINS:
TLS_DOMAINS=example.com,www.example.com APP_URL=https://example.com APP_KEY=base64:... ./blog2026/10/12 10:27:12 INFO listening addr=[::]:443 scheme=https
2026/10/12 10:27:12 INFO listening addr=[::]:80 scheme=http
2026/10/12 10:27:16 INFO a certificate is ready domain=example.com expires=2027-01-10T09:27:15.000Z
2026/10/12 10:27:16 INFO a certificate is ready domain=www.example.com expires=2027-01-10T09:27:15.000ZRun then serves HTTPS on :443, and HTTP/2 with it, and plain HTTP on
:80:
- Each domain's certificate is asked for as the app starts, once both
ports listen, rather than at the first visitor's handshake, so no one
waits the seconds it takes. A domain whose DNS doesn't reach the
machine shows in the log at once, as "couldn't get a certificate", and
is asked for again at its first handshake. Let's Encrypt checks that
the machine is the domain's with a challenge, on
:443first, TLS-ALPN-01, and else on:80, HTTP-01, so a host that closes:80gets its certificates too. - The certificates, and the key of the app's account with Let's Encrypt,
are kept in
CERTS_PATH,certs/beside the binary unless it says another, readable by the app's user alone, and renewed by the app as it runs, 30 days before they run out, or a third of their life before for a shorter one. Keep the directory from one deploy to the next, as the images' volume does: Let's Encrypt gives the same names five certificates a week. The starters'.gitignoreand.dockerignoreleave it out, as it holds private keys. - A certificate is asked for only for the domains named. A handshake for
any other name, as a scanner's by the machine's address, gets none,
rather than spend Let's Encrypt's limits on it.
APP_URL's host is one of them, or the app doesn't start. :80answers Let's Encrypt's challenges, and sends every other request to its path and query over HTTPS, by a 308, which keeps its method: at its own host when that's one of the domains, and otherwise, as a request by the machine's address, atAPP_URL's. The starters' HSTS has a browser go to HTTPS itself after its first visit.- A request over HTTPS reaches the app itself, so the session cookie is
for HTTPS only whatever
session.Configsays, andc.IP()is the browser's, with noTRUSTED_PROXIES. - Over HTTP/2, a browser's requests to the app share one connection,
where over HTTP/1.1 it opens six at most, of which each page's event
stream, as the auth starter's bell, holds one.
c.Eventsandc.Streamsend as they do over HTTP/1.1. - On SIGTERM, both ports stop taking connections together, and shut down as Shutting down has.
Setting TLS_DOMAINS is accepting Let's Encrypt's terms, its
subscriber agreement. It needs no
email: Let's Encrypt sends no mail of a certificate running out. To try a
deploy without spending its limits, get the certificates from its
staging, whose certificates no browser trusts:
ACME_URL=https://acme-staging-v02.api.letsencrypt.org/directoryThen empty CERTS_PATH as ACME_URL goes, as a certificate kept from
the staging would be served until it's renewed.
ADDR and HTTP_ADDR move the ports, as to :8443 and :8080 for a
try on a laptop; a redirect then has the HTTPS port in it.
:80 and :443 take the right to listen on a port below 1024. Docker
gives it in a container, where the images' nonroot user listens on them
as it is, unless the container shares the host's network, with
--network host:
docker run -d -p 443:443 -p 80:80 -v blog-data:/data \
-e APP_KEY=base64:... -e APP_URL=https://example.com \
-e TLS_DOMAINS=example.com,www.example.com blogThe images keep the certificates in /data/certs, on their volume. On a
machine of its own, under systemd, the app's unit gives it to the app's
user:
[Service]
User=blog
WorkingDirectory=/srv/blog
ExecStart=/srv/blog/blog
EnvironmentFile=/srv/blog/env
AmbientCapabilities=CAP_NET_BIND_SERVICE
Restart=on-failurewith TLS_DOMAINS, APP_URL and APP_KEY in /srv/blog/env, and the
certificates in /srv/blog/certs.
TLS_CERT and TLS_KEY, a certificate with its chain after it, and its
key, PEM files, are served in place of Let's Encrypt's, as an origin
certificate of Cloudflare's, for a site behind its proxy, or a company's
own CA's are:
TLS_CERT=/etc/ssl/blog/cert.pem TLS_KEY=/etc/ssl/blog/key.pem ./blogThey're read as the app starts, and again at a handshake, once a minute
at most, when either file's time has changed, as certbot's renewal
replaces them, so the app needn't start again. A pair that doesn't load,
as one read between the new certificate and its key, leaves the one
before in use, and the log says why. :80 sends a request for one of the
certificate's names to HTTPS at its own host, and any other to
APP_URL's, or the certificate's first name. Set both, and not with
TLS_DOMAINS, or the app doesn't start.
ConfigFromEnv reads TLS_CERT, TLS_KEY and HTTP_ADDR into
Config.TLS, and letsencrypt.FromEnv reads TLS_DOMAINS, CERTS_PATH
and ACME_URL, which the starters' main puts there:
cfg := tug.ConfigFromEnv()
if cfg.TLS.Certificates, err = letsencrypt.FromEnv(); err != nil {
log.Fatal(err)
}Without TLS_DOMAINS, FromEnv returns nil, which Config.TLS takes as
no source of certificates.
An app that makes its own Config sets the same:
certs, err := letsencrypt.New(letsencrypt.Config{
Domains: []string{"example.com", "www.example.com"},
Cache: autocert.DirCache("/var/lib/blog/certs"),
})
if err != nil {
log.Fatal(err)
}
app := tug.New(tug.Config{URL: "https://example.com", TLS: tug.TLS{Certificates: certs}})letsencrypt.Config's Cache takes any autocert.Cache, as a table of
the app's, for instances on more than one machine to share their
certificates, though behind a load balancer TLS ends there anyway.
TLS.Certificates takes any source of certificates with a
GetCertificate, as tls.Config has, and what more Run looks for,
which tug.Certificates lists: an autocert.Manager of the app's own is
one.
Package letsencrypt is autocert, golang.org/x/crypto/acme/autocert,
which the Go team keeps, and which adds about 1 MB to a binary. Package
tug doesn't import it, so an app that doesn't import letsencrypt
doesn't build it; the starters do, as one machine is where they're meant
to run.
A certificate for a domain a customer points at the app, asked for at
its first handshake, as Caddy's on-demand TLS is, a wildcard,
*.example.com, which Let's Encrypt checks by DNS alone, through the DNS
provider's API, and more than one app on one machine's ports are a
proxy's, as Caddy's or kamal-proxy's: see Behind a proxy.
tug dev leaves TLS_DOMAINS, TLS_CERT, TLS_KEY and HTTP_ADDR out
of the app's environment, and says so, as a .env copied from a deploy
would have a laptop ask for the deploy's certificates: under it, the app
serves HTTP.
Without TLS_DOMAINS, or TLS_CERT and TLS_KEY, Run serves plain
HTTP, and TLS ends in front of the app: at a proxy, a load balancer, or
the platform.
The session cookie is marked Secure, for HTTPS only, when the request
came over TLS, and a request from a proxy that ended TLS didn't. Set
Secure in the session's Config for an app served over HTTPS this way:
sessions, err := session.New(session.Config{Keys: keys, Secure: true})The starters set it from APP_URL, which starts with https:// for an
app served over HTTPS.
c.RedirectRoute, a form sent back with its errors, and the 409 that
reloads a page for a new build all carry a path rather than a whole URL,
so the app needn't know the scheme or host the proxy answers on. A link
that leaves the app, as in the auth starter's mail, does need them, which
is what APP_URL is for: a link made from the request's Host could
point to any site the request names.
The proxy should pass on the Host the request came with. A form that
doesn't validate goes back to the page in its Referer only when that's
on the request's host, and otherwise to /. And middleware.CSRF
compares the Origin with the Host, for a browser that doesn't send
Sec-Fetch-Site.
Behind a proxy, a request comes from the proxy's address. The auth
starter counts failed logins by email and address, c.IP(), so there
every client would count as one: five wrong passwords for an email, from
anyone, would make everyone wait out the minute for it, and five asks for
a reset link a minute would be the whole site's. A limit on a route by
address, with tug.Limit, has the same trouble. So name the proxies in
TRUSTED_PROXIES, which the starters hand middleware.TrustProxies:
TRUSTED_PROXIES=10.0.0.0/8 # a load balancer in the app's network
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
TRUSTED_PROXIES='*' # a platform whose proxy alone reaches the appA request from one of them then comes from the client in the proxies'
X-Forwarded-For, read from its end, where each proxy adds the address it
saw, so what a client writes in the header itself changes nothing
(Routing has how). * believes
whatever connects: it's for a platform whose proxy's addresses the app
can't name, where nothing else can reach the app, as anything else that
can would pick its own address. Behind a CDN and a load balancer, name
both: the CDN's ranges, which it publishes, and the load balancer's.
middleware.RequestID keeps an X-Request-ID that the proxy sets, when
it's short and plain, so the proxy's log and the app's share the ID.
The server Run starts has a ReadHeaderTimeout of 10 seconds, so a
client that sends its headers slowly can't hold a connection, and an
IdleTimeout of 2 minutes, after which a connection with no request is
closed. There's no timeout on reading a body or writing a response: a
slow upload or a long response is limited by the proxy's timeouts. Keep
the proxy's idle timeout, for its connections to the app, under the app's
two minutes, so that it never sends a request on a connection the app is
closing. A body that Bind reads is limited to Config.BodyLimit,
32 MiB, and the proxy may have a smaller limit of its own.
Each response of the starters says what a browser may do with it, by two
of package middleware's, which main.go has:
middleware.Headers(middleware.HeadersConfig{HSTS: strings.HasPrefix(cfg.URL, "https://")}),
middleware.CSP(middleware.CSPConfig{ReportPath: "/csp-reports", DevServer: assets.DevServer, StyleNonce: true}),Headers sends X-Content-Type-Options: nosniff, so a file is only ever
the type it's sent as; Referrer-Policy: strict-origin-when-cross-origin,
so a link to another site tells it which site it came from, and not the
page's whole URL; Cross-Origin-Opener-Policy: same-origin, so another
site's window can't reach into a page; and X-Frame-Options: SAMEORIGIN,
so another site can't put a page in a frame, under a page of its own, to
have a person click what they can't see. With HSTS, which the starters
set when APP_URL is https://, it sends Strict-Transport-Security: max-age=31536000: a browser that has reached the app over HTTPS keeps to
HTTPS for a year, whatever a link says, so a network in between can't hand
it a page over plain HTTP. It's for the app's host alone, with no
includeSubDomains, and isn't preloaded, as both promise more than the
app, and are slow to take back. A handler whose response needs one of them
otherwise sets its own, as a page another site frames.
Headers sends a Permissions-Policy too, which turns off the powerful
features no page of the app's uses, as a script that got into one would:
camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(),
midi=(), display-capture=(), browsing-topics=()They're the features Chrome knows by those names on every system, as it
logs an error for one it doesn't: Bluetooth isn't among them, as Chrome on
Linux has none. Passkeys keep theirs, as do fullscreen and autoplay,
which a video needs, each the app's own pages. An app whose page uses
one allows it in Permissions, by its allowlist, self, *, or origins
in quotes, which adds a feature too:
middleware.Headers(middleware.HeadersConfig{Permissions: map[string]string{
"camera": "self",
"payment": `self "https://pay.example.com"`,
}})CSP sends a Content-Security-Policy, which says what a page may run and
load, so a script that got into it some way but the app's own, as in a
person's text that an escape missed, doesn't run:
default-src 'self'; script-src 'nonce-…' 'strict-dynamic'; style-src 'nonce-…' 'self';
style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:;
connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self';
frame-ancestors 'self'; report-uri /csp-reports-
A script runs when it carries the response's nonce, a new random one each response, which the root template has as
.Nonce, for Vite's tags and its own scripts (Pages), or when such a script loads it, as Vite's entry loads the app's modules:'strict-dynamic', which every browser in use has. -
With
StyleNonce, which the starters set, a<style>is taken when it carries the nonce too, so a<style>that got into a page can't restyle it, as by hiding a warning; the build's stylesheets are the app's own. Style attributes stay as they are,style-src-attr 'unsafe-inline', as a nonce can't go on one, and the component libraries set them, and an SSR app's first visit has them. What injects a<style>in the browser takes the nonce from the root template's<meta property="csp-nonce" nonce="{{ .Nonce }}">(Pages): Vite's client, for the styles it injects undertug dev, and Inertia's, for its progress bar's and its error dialog's, which the starters'inertia.tsxhands it,defaults: { nonce }. The React starter hands it Radix's dialogs too, which take theirs fromget-nonce'ssetNonce, and takes sonner's styles from its stylesheet, as sonner injects a<style>with no nonce, which a plugin in itsvite.config.tstakes out. WithoutStyleNonce, any inline style gets in, for an app with a library that injects a<style>with no nonce, and a style that gets into a page does little a script can't. -
Images may be
data:andblob:URLs, as the starters' favicon is, and fontsdata:, as Vite writes small ones into the CSS. -
Otherwise what a page loads and connects to is the app's own, a form goes to the app alone, nothing runs as a plugin, no
<base>moves the page's links, and only the app's own pages may frame it. -
Sourcesadds to a directive. The auth starter adds its bucket's address,(*storage.S3).Origin(), toimg-src, when its photos are on S3, as they're links to it. An app that uses what's another site's adds it the same way, as a payment provider's frames and API:middleware.CSP(middleware.CSPConfig{ ReportPath: "/csp-reports", Sources: map[string][]string{ "frame-src": {"https://js.stripe.com"}, "connect-src": {"https://api.stripe.com"}, }, })Its script needs no source:
<script src="https://js.stripe.com/v3" nonce="{{ .Nonce }}"></script>in the root template runs by the nonce. -
DevServeris Vite's. Undertug dev, while the dev server runs, its styles, images and fonts are let in, and its connection that reloads the pages as they change.
What the policy blocks, the browser says in its console, and reports to
ReportPath, which CSP answers itself, before any route and CSRF, and
logs, as a warning with the page, the directive, what was blocked and
where it was asked for. It's report-uri, which every browser sends as it
happens: Chrome's newer report-to batches its reports, and sends them
over HTTPS alone. A report needs no login, as a browser sends one
without, so a report in the log is what a browser said, or what anyone
made up.
An app that has grown, whose templates have scripts that don't carry the
nonce yet, can send the policy as Content-Security-Policy-Report-Only
first, with ReportOnly: true: the browser blocks nothing, and reports
what it would have, for the log to show what to fix before the app
enforces it. X-Frame-Options keeps other sites from framing the pages
meanwhile.
storage.Local sends a policy of its own with each file it serves,
sandbox, in place of the app's, so a file that's a page can't run
anything.
Trusted Types, require-trusted-types-for 'script', which has the
browser refuse a string for innerHTML and the other places a script
runs from unless a policy of the page's made it, aren't in the policy
yet: Vue's runtime and Svelte's make policies of their own, but
Inertia's client writes innerHTML with none, for each visit's head
elements, its progress bar and its error dialog, so every visit with a
head would fail. They wait for Inertia's client to make one.
On SIGTERM or SIGINT, Run logs "shutting down" and stops taking
connections, on both ports of an app that serves HTTPS itself. The requests in flight run to their end, for up to
Config.ShutdownTimeout, 10 seconds unless the app sets another. Then
Run returns nil and the starters' main returns. Requests still running
after the timeout have their connections closed, and Run returns an
error, "tug: requests still running after 10s", which the starters'
main passes to log.Fatal.
A platform gives the process some time between SIGTERM and killing it.
Docker's docker stop gives 10 seconds, the same as tug's default, so
choose a timeout under the platform's. In the plain starter's main:
cfg := tug.ConfigFromEnv()
cfg.ShutdownTimeout = 8 * time.Second
app, err := newApp(cfg, build, keys)The Dockerfile's ENTRYPOINT is the binary itself, with no shell in
between, so the signal reaches it. Work that a handler starts in a
goroutine of its own isn't a request, and Run doesn't wait for it; what
app.Go runs, it does. The auth starter's job queue, which sends its
mail, runs that way: as the app shuts down, it takes no more jobs, and
gives the ones running 10 seconds, its Grace, alongside the requests'
ShutdownTimeout. A job still running after that is put back, and runs at
the next start. So shutting down takes as long as the longer of the two,
and a moment more to put jobs back: keep it under the platform's grace
period. jobs.md has the rest. The Node of an app with
server-side rendering stops the same way, with the renders it has, and
five seconds to.
An app's commands, as the auth starter's jobs,
which lists the jobs that failed for good and runs them again, run in
place of the server, on the same database, and exit. In a container,
one runs beside the server, with its environment: docker exec <container> /server jobs. A command runs in the app as main makes it,
so it needs what the server needs, APP_KEY among it, which the
container has.
Both starters answer GET /up with 200 and up while the app is serving,
for a load balancer's or a platform's health check to ask. The auth
starter's pings its database as well, and answers 503 when that doesn't
answer. Like any request, each check is a line in the request log.
Put the new key in APP_KEY, and the old one in APP_PREVIOUS_KEYS:
APP_KEY=base64:<the new key>
APP_PREVIOUS_KEYS=base64:<the old key>The first key encrypts, and every key decrypts. Each response writes a
visitor's session back to its cookie, with the new key, so sessions move
over as people use the app. A session that goes unused for its lifetime,
2 hours unless session.Config.Lifetime says otherwise, has ended anyway,
so once that long has passed, drop the old key. In the auth starter, a
login that ticked "Remember me" lasts a month without a visit, so keep the
old key for a month there. Its reset links, which last an hour, its
verification links, a day, and the links to the photos on its own disk,
two days at most, are checked against every key too. Its
two-factor secrets are sealed with the key: as the app starts with more
than one key, it seals every user's again with the new one, so none of
them is lost when the old key goes.
A new APP_KEY without the old one in APP_PREVIOUS_KEYS logs everyone
out: no cookie decrypts, so every visitor starts a new session. That's
what to do when the old key has leaked: don't keep it.
tug logs through slog.Default() and never sets it. Unless the app sets
it, that's a line of text on the standard error:
2026/09/25 16:44:09 INFO listening addr=[::]:8080
2026/09/25 16:44:10 INFO request method=GET path=/ status=200 size=806 duration=1.057625ms route="GET /" request_id=S53TAMBAGDIAG5OAIFBGPRG6KN
2026/09/25 16:44:11 WARN request method=GET path=/build/.vite/manifest.json status=404 size=19 duration=14.833µs route="GET /build/{path...}" request_id=5N5DHQOALQZIZMXK3HVCPXNPD3
2026/09/25 16:44:12 INFO shutting down timeout=10smiddleware.Logger logs the "request" lines: the method, the path
without its query string, which can carry tokens, the status, the size,
the time taken, the route that answered, and the request's ID. A 5xx is logged at Error, a 4xx at
Warn, and the rest at Info. A server error's details go to the log, in a
"request failed" line with the error and a panic's stack, and not to the
response while APP_DEBUG is off.
The queue's lines of a job have the ID of the request that pushed it, when the queue carries it, as the auth starter's does (Background jobs): a job that failed hours later, on another instance, is found by its request's ID, and the other way round.
2026/10/01 19:29:46 INFO request method=POST path=/register status=303 size=0 duration=27.871042ms route="POST /register" request_id=SCK2WDZAHS33MHC2HQCFU7KWMK
2026/10/01 19:29:46 WARN a job failed, and will run again kind=verify-mail job=6 attempt=1 at=2026-10-01T19:29:47.319+07:00 err="mail: dial tcp 127.0.0.1:1: connect: connection refused" request_id=SCK2WDZAHS33MHC2HQCFU7KWMKFor JSON lines, as most log collectors want, set the default at the start
of main:
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
// ...
}Set it before Run, which gives the http.Server's own error log the
handler that's the default when it starts. From then on the log
package's output goes through the handler too, log.Fatal's included.
slog.HandlerOptions sets the level: Level: slog.LevelWarn leaves out
the Info lines. routing.md lists what tug logs.
tug counts nothing itself: a client of the app's monitor's does,
Prometheus's own Go client, OpenTelemetry's, or a platform's agent, in the
app, from what it sees. tug gives it what it can't see from outside: the
route that answered each request, tug.RouteOf
(Routing), and how each run of a
job went, queue.Config's Observe
(Background jobs). With Prometheus's client,
go get github.com/prometheus/client_golang, in the app:
var (
requests = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "How long the app took to answer, by method, route and status.",
}, []string{"method", "route", "status"})
runs = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "job_run_duration_seconds",
Help: "How long each run of a job took, by kind and how it went.",
}, []string{"kind", "outcome"})
)
// metrics times each request, by the route that answered it, which the
// router has said once the handler has run.
func metrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sent := &status{ResponseWriter: w, code: http.StatusOK}
next.ServeHTTP(sent, r)
route := cmp.Or(tug.RouteOf(r), "none")
requests.WithLabelValues(r.Method, route, strconv.Itoa(sent.code)).Observe(time.Since(start).Seconds())
})
}
// status keeps the status a response was sent with.
type status struct {
http.ResponseWriter
code int
}
func (s *status) WriteHeader(code int) {
s.code = code
s.ResponseWriter.WriteHeader(code)
}
// Unwrap lets the events and streams flush through it.
func (s *status) Unwrap() http.ResponseWriter { return s.ResponseWriter }app.Use(metrics, middleware.RequestID(), middleware.Logger(), middleware.Recover())
q := queue.New(queue.Config{Store: &jobs{db: db}, Observe: func(r queue.Ran) {
runs.WithLabelValues(r.Kind, r.Outcome.String()).Observe(r.Took.Seconds())
}})
// The metrics, on a port of their own, which the scraper reaches and the
// proxy doesn't.
app.Go(func(ctx context.Context) error {
server := &http.Server{Addr: ":9090", Handler: promhttp.Handler()}
go func() {
<-ctx.Done()
server.Shutdown(context.Background())
}()
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
})- A request's label is its route, not its path, which has as many values
as the app has posts, each a series Prometheus keeps. A request no route
answered, a 404, is
none. - What the metrics say, the app's routes and how busy each is, is for the scraper alone: a port of their own the proxy doesn't reach, as above, or a route behind a token.
- Each instance has its own, which Prometheus scrapes each of, and adds up.
- What's read as it's scraped, as the jobs waiting, by kind, from the
jobs table, or the database's connections,
db.Stats(), is a gauge with a function,promauto.NewGaugeFunc. - The auth starter has none of this, as not every app has a monitor: its log lines have the route.