How to build for production
This guide walks through building a Piko application for production. Steps cover compiling assets, building the server binary, choosing a run mode, and handing off to a process manager. See the CLI reference for every piko subcommand and the bootstrap options reference for every With* option.
Project layout
A scaffolded Piko project has two entry points under cmd/:
my-app/
├── cmd/
│ ├── generator/
│ │ └── main.go # Asset/manifest generator
│ └── main/
│ └── main.go # Server entry point
├── actions/
├── components/
├── pages/
└── partials/
cmd/generator/main.go produces the template and asset manifests. cmd/main/main.go is the HTTP server.
The short version
piko build
piko build runs the generator, then compiles a self-contained binary to bin/app: the compiled manifest, pages, collections, asset blobs, storage, and the registry snapshot are embedded (via the piko_embed build tag), so the binary serves with no dist/ or .piko/ beside it. Pass --no-embed for a binary that reads both from disk instead. The steps below describe what happens under the hood and the manual equivalents.
Step 1: Generate assets
Build and run the generator first. The generator compiles every .pk template, processes assets, emits a manifest the server reads at startup, and emits the embedded runtime payload (dist/embed/ plus dist/embed_gen.go).
go build -o bin/generator cmd/generator/main.go
bin/generator all
all invokes piko.GenerateModeAll, which covers template compilation, SQL-querier generation, and asset processing. Other modes are available if you need finer control:
| Command | Mode constant | Effect |
|---|---|---|
bin/generator manifest | piko.GenerateModeManifest | Compile templates and emit the manifest only. |
bin/generator sql | piko.GenerateModeSQL | Run the querier generator only. |
bin/generator assets | piko.GenerateModeAssets | Asset processing only. |
bin/generator all | piko.GenerateModeAll | Everything. |
Step 2: Build the server binary
Build a statically linked, stripped, self-contained binary:
CGO_ENABLED=0 go build \
-tags piko_embed \
-ldflags="-s -w" \
-o bin/app \
./cmd/main
-tags piko_embedcompiles the generated embedded runtime payload into the binary, making it self-contained. Omit the tag for a binary that servesdist/and.piko/from disk. Always regenerate (step 1) before a tagged build so the payload matches the sources.CGO_ENABLED=0produces a static binary with no C dependencies. Use this unless you are linking a CGO-only SQLite driver.-ldflags="-s -w"strips debug symbols and DWARF information for a smaller binary.- Development builds (
go run ./cmd/main dev,go test) never carry the payload: the generated file is excluded without the tag, and even a tagged binary run in dev mode serves from files.
Step 3: Configure the application
Configure the framework in func main with With* options. Branch on a build flag, environment variable, or the run-mode argument if you need different settings between environments.
ssr := piko.New(
piko.WithPublicDomain("yourdomain.com"),
piko.WithForceHTTPS(true),
piko.WithAutoNextPort(false),
)
The first command-line argument to ssr.Run carries the run mode (dev, prod) and controls watch mode automatically. Use piko.WithWatchMode(...) if you need to override that default.
Step 4: Run the binary
Piko applications accept the run mode as the first command-line argument:
./bin/app prod
| Run mode | Constant | Behaviour |
|---|---|---|
dev | piko.RunModeDev | Hot reload, file watching, verbose dev output. |
dev-i | piko.RunModeDevInterpreted | Yaegi interpreter: no recompilation, slower runtime. |
prod | piko.RunModeProd | Compiled templates, AST caching, production HTTP stack. |
The scaffolded cmd/main/main.go reads the argument and passes it to ssr.Run:
package main
import (
"os"
"piko.sh/piko"
"piko.sh/piko/wdk/logger"
)
func main() {
logger.AddPrettyOutput()
command := piko.RunModeDev
if len(os.Args) > 1 {
command = os.Args[1]
}
ssr := piko.New()
if err := ssr.Run(command); err != nil {
panic(err)
}
}
The example above is the minimal shape. The wizard-generated scaffold goes one step further and constructs the SSR via internal.NewServer(command), a thin project-local wrapper under internal/piko.go that holds the shared option set. Both cmd/main and cmd/generator configure the framework the same way. If you scaffold with piko new, edit internal/piko.go to adjust framework options instead of scattering piko.With* calls across each binary.
Switch AddPrettyOutput() to AddJSONOutput() for structured logs in production.
Step 5: Expose the health probe
Piko runs two HTTP servers. One serves the application on port 8080 (default), the other serves the health probe on port 9090 (default). The default bind address for the health probe is 127.0.0.1, which keeps it off the public network. Expose it to the orchestrator by binding to all interfaces:
ssr := piko.New(
piko.WithHealthEnabled(true),
piko.WithHealthProbePort(9090),
piko.WithHealthBindAddress("0.0.0.0"),
piko.WithHealthLivePath("/live"),
piko.WithHealthReadyPath("/ready"),
piko.WithHealthMetricsPath("/metrics"),
piko.WithHealthMetricsEnabled(true),
)
Step 6: Wire up a process manager
Piko does not supervise itself. Use a process manager that restarts the binary on crash.
systemd
[Unit]
Description=MyApp (Piko)
After=network.target
[Service]
ExecStart=/opt/myapp/bin/app prod
WorkingDirectory=/opt/myapp
Restart=on-failure
User=myapp
EnvironmentFile=/etc/myapp/env
[Install]
WantedBy=multi-user.target
Docker
A multi-stage Dockerfile:
# Build stage
FROM golang:1.26 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ENV CGO_ENABLED=0
ENV GOOS=linux
RUN go build -o bin/generator ./cmd/generator
RUN bin/generator all
RUN go build -tags piko_embed -ldflags="-s -w" -o bin/app ./cmd/main
# Runtime stage
FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=build /app/bin/app /app/app
CMD ["/app/app", "prod"]
The distroless/static:nonroot base image keeps the container small and runs as a non-root user by default. Because the binary is self-contained (the piko_embed tag embeds the manifest, pages, collections, blobs, storage, and the registry snapshot), the runtime stage copies nothing else and runs with a read-only root filesystem out of the box. If your project serves raw static files from a top-level assets/ directory (favicons, images), copy that with --chown=nonroot:nonroot.
For a file-based image instead, build without the tag and copy dist/ and .piko/ with --chown=nonroot:nonroot: the default runtime opens read-write sandboxes under .piko at boot (blob storage, upload tmp, the AST cache, and the WAL persistence for the in-memory registry), the build stage runs as root with restrictive modes, and a read-only .piko mount fails at the WAL open. Prune the regenerable .piko/cache, .piko/logs, and .piko/tmp in the build stage first.
Whichever image you build, anything written inside the container at runtime is ephemeral and lost on restart or rollout. Send runtime writes to a configured backend instead: register storage_provider_s3 (or another external provider) for the bytes and a shared Postgres registry for the metadata. A shared backend also lets two releases run side by side during a canary or rolling deploy; see how to canary and rolling deploys for that flow.
Kubernetes
A minimal deployment manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 3
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: registry.example.com/myapp:latest
ports:
- containerPort: 8080
name: http
- containerPort: 9090
name: health
livenessProbe:
httpGet:
path: /live
port: health
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /ready
port: health
initialDelaySeconds: 5
periodSeconds: 10
envFrom:
- secretRef:
name: myapp-secrets
Production checklist
Before the first deploy:
- HTTPS: terminate TLS directly with
piko.WithTLS(...)(see TLS how-to) or front with a reverse proxy. - Security headers: on by default, tunable through
piko.WithSecurityHeaders(...). - CSRF: on by default for actions.
- Secrets: loaded into your
func mainfrom any source (env vars, secret manager, mounted files) and passed to the relevantWith*option (see secrets how-to and secrets resolvers). - Rate limiting: configure
piko.WithRateLimit(...)if exposed to the public internet. - Assets: run the generator before building the binary.
- Logging: swap to JSON in production (
logger.AddJSONOutput()). - Health probe: accessible to the orchestrator with
piko.WithHealthBindAddress("0.0.0.0"). - Metrics:
/metricson the health probe server whenpiko.WithHealthMetricsEnabled(true). - Process manager: configured to restart on failure.
Scaling
Piko applications are stateless when sessions, file uploads, caches, and rate-limit counters all live in external stores. For horizontal scaling:
- Sessions: back by Redis, Valkey, or the database via the cache service.
- Uploads: use
storage_provider_s3,storage_provider_gcs, or another external provider. - Cache: use
cache_provider_redis,cache_provider_valkey, or multilevel. - Rate limiter: use a Redis backend so counters survive instance restarts and replicas share the same state.
For vertical scaling inside one instance, tune GOMAXPROCS, database connection pools (piko.WithPostgresMaxConns, piko.WithPostgresMinConns), and the HTTP request timeout (piko.WithRequestTimeout).