Shipping
Deployment
Build the binary, run it in a container or under systemd, set what production needs, and put it behind TLS.
A collage site is a Go program, so what you deploy is a compiled binary. Templates
and static files are embedded in it, so it runs from any directory with nothing
copied beside it — except plugins-config.json, if you configure plugins; see
below. This page is about running that binary on a server; for a site
with no server at all, see Static export.
Building: collage build
collage build
./bin/mysite
collage build runs the go build you would otherwise have to remember:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o bin/mysite .
- CGO off, because collage and the standard library need no C, and a static binary can go into an image that contains nothing else.
-trimpath, so the binary does not carry the paths of the machine that built it.-s -wdrops the debug tables, which is most of the size.- linux/amd64 by default, not this machine. A binary built for a Mac does not run
in a Linux container, and
exec format erroron a server is the wrong place to find that out.
| Flag | Default | Meaning |
|---|---|---|
-o path |
bin/<module name> |
Where the binary is written. |
-os name |
linux |
Target operating system. |
-arch name |
amd64 |
Target architecture — arm64 for Graviton or Ampere machines. |
-i |
off | Offer to write a Dockerfile and a systemd unit beside the binary. |
The binary goes in bin/, not dist/: dist/ belongs to collage export, whose
-clean would delete a binary sitting in it.
There is no collage start. Running a compiled binary needs nothing remembered, and
a start command could only run go run . — which puts the Go toolchain in your
production image and compiles on every boot.
collage build -i: a Dockerfile and a systemd unit
With -i, collage build asks whether to write a Dockerfile and a systemd unit
next to the binary:
$ collage build -i
Building mysite for linux/amd64.
Write a Dockerfile? [y/N]: y
Write a systemd unit? [y/N]: y
✓ bin/mysite
linux/amd64 · 9.3 MB
wrote bin/Dockerfile docker build -f bin/Dockerfile .
wrote bin/mysite.service
9.3 MB · linux/amd64 · 4.1s
They go in bin/ because they are generated, and the project root is for what you
wrote. An existing file is never overwritten — these are files you are expected to
edit — and a scaffolded project's .gitignore ignores only the binary, so you commit
them like any other file.
The Dockerfile has two stages: it builds in the Go image and ships the binary alone on a distroless base:
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /mysite .
FROM gcr.io/distroless/static-debian12
WORKDIR /srv
COPY --from=build /mysite /usr/local/bin/mysite
ENV HOST=0.0.0.0 PORT=8080
# ENV COLLAGE_CSRF_KEY=
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/mysite"]
Build it from the project root with docker build -f bin/Dockerfile .. There is no
COPY of templates or static files, because they are inside the binary. The final
stage copies the binary — and, since v0.11.1, plugins-config.json when the project
has one when the Dockerfile is written; see Plugin configuration. The WORKDIR
matters for the page cache and for that file; see below. Set COLLAGE_CSRF_KEY from
your platform's secrets rather than in the file.
The systemd unit runs the binary from /usr/local/bin, in /srv/<name>, as a user
of the same name, listening on 127.0.0.1:8080 for a reverse proxy in front. Adjust
the user, directory and path before installing it at
/etc/systemd/system/<name>.service:
systemctl daemon-reload && systemctl enable --now mysite
Environment
The scaffolded main.go reads four variables. Nothing reads .env files in
production — those are for collage dev — so set them wherever the binary runs.
| Variable | Default | Set it to |
|---|---|---|
HOST |
localhost |
0.0.0.0 in a container. localhost accepts nothing from outside the machine, which is right behind a local reverse proxy and wrong everywhere else. |
PORT |
3000 |
Whatever your platform assigns. The binary's -port flag overrides it. |
COLLAGE_CSRF_KEY |
generated | At least 32 random bytes. Set this. |
COLLAGE_DEV |
unset | Nothing — leave it unset. collage dev sets it to 1, which turns on development mode: templates and static files read from disk, an in-memory cache that is never read, full error chains on error pages. A server running with it is a development server. |
Make a key with:
openssl rand -hex 32
Without one, collage generates a key per process and warns at startup. That is fine on your machine and wrong on a server, for two reasons:
- Forms break across restarts and instances. A form rendered before a deploy is refused when submitted after it, and one instance refuses what another issued. Keep the key the same on every instance and across restarts.
- Cached pages with forms are rendered again after every start. The forgery-token placeholder in a cached page is derived from the key, so a page stored under the previous process's key is treated as a miss and rendered afresh. Pages without a form are unaffected; the rest of the disk cache survives the restart either way. See Caching.
Rotating the key is safe but not free: forms rendered before the change are refused, and cached pages with a form are rendered again once each.
Embedded templates and static files
A scaffolded project embeds both:
//go:embed all:templates
var templatesFS embed.FS
//go:embed all:static
var staticFS embed.FS
Templates are given to collage as Template.FS, and static files are mounted from
fs.Sub(staticFS, "static"). In development mode the directories on disk win, so
editing a template shows on the next request; in production only the embedded copy
is read, so what you tested is what runs, wherever the process starts.
Content your pages read at runtime — Markdown files, a JSON catalogue — is yours to
embed the same way. collage-docs embeds its content/ directory for exactly that
reason, and reads it from disk in development. Name such a directory in
Config.DevWatch (since v0.10.0) and editing a file
in it reloads the browser too.
Plugin configuration
The scaffolded main.go reads plugins-config.json with
collage.LoadPluginConfig("plugins-config.json") — a path relative to the working
directory, not to the binary, and not embedded. A missing file is not an error: every
plugin silently runs on its defaults. So a server started somewhere the file is not
ignores your plugin settings without a word.
The Dockerfile collage build -i writes copies it when the project has one at the
time the Dockerfile is written (since v0.11.1; earlier ones copied only the binary).
Add the line yourself if you create the file later — collage build -i never
overwrites a Dockerfile — beside the WORKDIR the process starts in:
WORKDIR /srv
COPY --from=build /mysite /usr/local/bin/mysite
COPY --from=build /src/plugins-config.json /srv/plugins-config.json
or embed it, so it travels in the binary like the templates:
//go:embed plugins-config.json
var pluginConfigJSON []byte
var pluginConfig map[string]json.RawMessage
if err := json.Unmarshal(pluginConfigJSON, &pluginConfig); err != nil {
return nil, fmt.Errorf("plugin configuration: %w", err)
}
With systemd, keep the file in the unit's WorkingDirectory.
Graceful shutdown
app.ListenAndServe traps SIGINT and SIGTERM. On either it stops accepting
connections, waits up to Server.ShutdownTimeout (10 seconds by default) for
requests in flight to finish, runs every plugin's Shutdown, and returns nil. A
container runtime that sends SIGTERM and waits gets a clean drain with nothing
added. The generated systemd unit sets TimeoutStopSec=30, comfortably longer than
the shutdown timeout, so systemd does not kill a process that is still draining.
If the drain runs out of time — a request still open when the timeout passes — the
plugins are shut down anyway, and ListenAndServe returns an error saying so
(collage: server shutdown: context deadline exceeded), as it does for a plugin
whose Shutdown failed. The scaffold's main.go passes that to log.Fatalf, so
the process exits with status 1 rather than 0; a platform that treats a non-zero
exit on stop as a crash will say so.
If you raise ShutdownTimeout, raise your platform's grace period with it.
Health checks
The project collage new scaffolds answers /healthz with a small JSON body whose
status is ok. It is a document, not a page, so it involves no
template and cannot start failing because one did. It is Dynamic(), so every
check really reaches the process.
Point your platform's liveness check at it. It tells you the process is up and
serving. A readiness check that should also fail when your database is unreachable
is a document of your own, written the same way; return an error and it answers 500.
A project made with collage new --template minimal has no /healthz; copy the
demo scaffold's documents/health.go if you want one.
The page cache in production
The scaffold configures a disk cache:
Cache: collage.CacheConfig{
Enabled: true,
Type: "disk",
Dir: cacheDir, // ".cache"
DefaultTTL: 5 * time.Minute,
},
Rendered pages survive a restart, so a redeploy of the same build does not re-render the site into a cold cache. What a new build finds depends on what changed:
- The cache is namespaced by a hash of the binary. A new build reads a different
directory,
.cache/<hash>, so it never serves pages the previous build rendered. Nothing has to be cleared for correctness — but nothing clears it for space either: the directories of earlier builds are never removed, so a server that is redeployed in place, rather than as a fresh container, accumulates one per build. Delete the old ones from your deploy script. SetCache.Version— a commit, a release tag — if something outside the binary decides what pages look like. - The directory is relative to the working directory. It is the one thing about
a scaffolded project that depends on where it was started. Set
WORKDIRin the container orWorkingDirectoryin the unit, or giveDiran absolute path, and make sure the process can write there. - A cache it cannot write to is not an error. At startup, a directory that cannot be created — a read-only filesystem, a working directory the process may not write to — makes collage fall back to an in-memory cache with a warning, rather than fail to start (since v0.11.0). Once running, a write that fails is logged, the page is served uncached, and the next request renders it again: slow, not broken. Look for the warning, and check that the directory fills up after a deploy.
- Each instance has its own. In a container the directory is inside the container, so each instance fills its own cache, at one render per page per instance.
Invalidation with several instances
app.InvalidateTags drops entries from the cache of the process it runs in. With
one instance, a CMS webhook that invalidates a post's tag updates the site. With
several, it updates only the instance the webhook reached; the others keep serving
the old page until its TTL runs out.
Choose one of three answers: give pages that change Incremental(ttl) so a stale
copy expires on its own, send the webhook to every instance, or configure a shared
store through Cache.Store that implements collage.TaggedCache, which lets one
invalidation reach entries any instance wrote. See Caching.
A shared store holds pages, not data. Values kept with
collage.Cached live in each process's
memory, whatever Cache.Store is, and an invalidation reaches only the instance it
ran in. After a webhook reaches instance A, instance B renders a fresh page — the
shared store dropped it — from the old value it still holds. Give those values a
TTL short enough to live with, or send the invalidation to every instance.
TLS, behind a proxy
collage serves plain HTTP, and has no ListenAndServeTLS. Terminating TLS well means
certificates, renewal, HTTP/2, HSTS and a redirect from port 80, and every platform
this runs on — a load balancer, a reverse proxy, Cloudflare, Fly, Render — already
does it better than a framework flag could.
Put the binary behind one, and make sure the proxy sends X-Forwarded-Proto: https.
collage uses it to mark the forgery-token cookie Secure, so a site served over
HTTPS never sends that cookie over plain HTTP. A minimal Caddy configuration, which
also obtains the certificate:
example.com {
reverse_proxy 127.0.0.1:8080
}
With nginx, proxy_set_header X-Forwarded-Proto $scheme; in the location block
does the same.
If you want the binary to terminate TLS itself, app.Handler() is an ordinary
http.Handler:
srv := &http.Server{
Addr: ":443",
Handler: app.Handler(),
ReadHeaderTimeout: 15 * time.Second,
}
log.Fatal(srv.ListenAndServeTLS(certFile, keyFile))
Doing that means you own the timeouts and the signal handling that ListenAndServe
did for you, and you call app.Shutdown(ctx) yourself so plugins shut down.
Timeouts
ListenAndServe applies the timeouts in Config.Server:
| Field | Default | Bounds |
|---|---|---|
ReadTimeout |
15s | Reading a request, headers included — a client that sends headers slowly cannot hold a connection open. |
WriteTimeout |
30s | Writing the response. A page whose data takes longer than this is cut off. |
IdleTimeout |
60s | A keep-alive connection waiting for its next request. |
ShutdownTimeout |
10s | The graceful drain on SIGTERM. |
MaxBodyBytes |
4 MiB | An action's request body, unless the action sets its own. Negative is unbounded. |
Server: collage.ServerConfig{
Host: envString("HOST", "localhost"),
Port: port,
WriteTimeout: time.Minute,
},
The time a data handler may take is a different setting: each fragment's
WithTimeout, or Template.Timeout (5 seconds) for fragments and documents that
set none. Keep it well under WriteTimeout, so a slow upstream turns into a
fragment's fallback rather than a connection closed halfway through the page. See
Configuration.
Logs
With no Config.Logger, collage logs to slog's default handler — or, on a
terminal, to one formatted for a person. On a server, where logs are read by a
machine, pass a JSON handler:
Logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)),
A checklist
COLLAGE_CSRF_KEYset, the same on every instance.HOST=0.0.0.0in a container;PORTfrom the platform.- A writable working directory for
.cache, and old.cache/<hash>directories removed on deploy. plugins-config.jsonin the working directory, or embedded, if you configure plugins.COLLAGE_DEVunset.- TLS at the proxy, with
X-Forwarded-Protopassed on. - The platform's stop grace period longer than
Server.ShutdownTimeout. - The liveness check on
/healthz. go test ./...in CI before the build — see Testing.