Deploying Loom to Production
Take a Loom project from loom serve on your laptop to a running production container. Uses the artefacts loom deploy produces.
What you'll have at the end
- A Docker image that runs
loom serveagainst your Threads - A production database on PlanetScale (or any MySQL 8 host)
- Secrets supplied via env vars — no plaintext in the image
- A health-checked container that auto-restarts
Step 1 — Run loom deploy
cd my-app
loom deploy
This emits three files:
Dockerfile # multi-stage build, runs as non-root
.dockerignore # excludes .git/, .env, .loom/pending-reports/
deploy/start.sh # required-env-var check + exec loom serve
loom deploy --force regenerates all three (use after upgrading the Loom CLI to a newer version).
Step 2 — Review the Dockerfile
The shipped Dockerfile is minimal:
FROM golang:1.24-alpine AS build
WORKDIR /src
RUN apk add --no-cache git ca-certificates
RUN go install github.com/orbweaver-dev/loom@latest
FROM alpine:3.20
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 loom
COPY --from=build /go/bin/loom /usr/local/bin/loom
WORKDIR /app
COPY --chown=loom:loom loom.yaml ./
COPY --chown=loom:loom threads/ ./threads/
COPY --chown=loom:loom custom/ ./custom/
USER loom
EXPOSE 8080
ENV LOOM_PORT=8080
ENTRYPOINT ["loom", "serve"]
Notes:
- The Loom binary is fetched in the build stage via
go install. Pin a specific version in production:RUN go install github.com/orbweaver-dev/loom@v0.1.0. custom/is copied even if empty — that's where ejected models live.- The runtime image runs as a non-root
loomuser (UID 10001). .loom/is not copied — it's regenerated on demand and contains nothing the running server needs in interpreter mode.
Step 3 — Apply migrations against production
Before deploying the container, your production database needs the right schema. Connect to the production DSN:
export DATABASE_URL='mysql://...prod-branch...'
loom stitch --preview # always preview first
loom stitch -y # apply
For PlanetScale: stitch against a feature branch, then open a deploy request to merge into main.
Step 4 — Build and push the image
docker build -t registry.example.com/my-app:v0.1.0 .
docker push registry.example.com/my-app:v0.1.0
Step 5 — Run the container
docker run -d \
--name my-app \
--restart unless-stopped \
-p 8080:8080 \
-e DATABASE_URL='mysql://...prod...' \
-e LOOM_SECRET="$LOOM_SECRET" \
registry.example.com/my-app:v0.1.0
For LOOM_SECRET, source from your secret manager (Vault, AWS Secrets Manager, Kubernetes Secret, etc.) — never bake it into the image.
Step 6 — Verify
curl http://localhost:8080/healthz
# {"ok":true,"threads":3,"plugins":0}
If threads is 0, your threads/ directory didn't make it into the image — check .dockerignore.
Health checks
For Kubernetes:
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 2
periodSeconds: 3
For Docker Compose:
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 3
Logging
The runtime logs to stdout via log/slog in JSON format by default. Pipe to your log aggregator (Loki, Datadog, CloudWatch). Each request emits one line with method, path, status, dur_ms, req_id, and the requesting remote address.
Scaling
Loom's runtime is stateless — every request loads its data from the database. To scale horizontally, run multiple containers behind a load balancer. JWTs are HS256-signed; every replica needs the same LOOM_SECRET to verify them.
What's not in the box
- TLS termination. Run behind a reverse proxy (nginx, Caddy, ALB) or use a service mesh.
- Rate limiting. Same — terminate upstream.
- Auto-migrations on container start. The Dockerfile does NOT run
loom stitchat boot; that's a separate step you do when you're ready to apply schema changes. Coupling them risks unintended migrations on every deploy.
See also
- Getting Started → Connecting to PlanetScale — DSN format
- Reference → CLI Command Reference —
loom deployandloom serveflags