Deploying on a PaaS¶
There is a nixpacks.toml in the repository root, so any platform that builds
with Nixpacks -- Coolify, Dokploy, Railway, Zeabur,
Easypanel -- can deploy Zoomies straight from the source. Point it at
github.com/eyupio/zoomies, set the handful of variables below, give it a
volume, and it builds the UI, builds the binary and starts the controller.
InstaPods deploys with commands you give it rather than Nixpacks, and offers
no Docker, so it has a guide of its own: Deploying on InstaPods.
Nothing about the build is special to any one platform: it is the same two
steps as deploy/Dockerfile, in the order that matters. internal/api embeds
internal/api/webdist, so the Svelte UI is built first and the Go build embeds
what it produced. Nixpacks' own Node and Go providers would each find half of
that and neither would wait for the other, which is why every phase is written
out in the file rather than detected.
What you get, and what you do not¶
A controller. The API, the web UI, the webhook endpoint, the scheduler and the database.
No runners on that host. A PaaS does not give a container a Docker socket,
and Zoomies will not pretend otherwise: nixpacks.toml sets
ZOOMIES_AGENT_EMBEDDED=false, so the controller comes up knowing it runs
nothing itself. Jobs would otherwise queue against a host that can never start
a container -- which the Overview would tell you, eventually, in the problems
panel. Runners come from agents you join on machines that do have a container
runtime; see Adding a host below.
That split is a reasonable shape, not a consolation prize: the controller is the part you want always-on, backed up and reachable from GitHub, and the runner hosts are the part you want close to your own network and cheap to replace.
Set these¶
| Variable | Why |
|---|---|
ZOOMIES_EXTERNAL_URL |
The https address the platform gives you. Webhooks are delivered to $ZOOMIES_EXTERNAL_URL/webhooks/github, the session cookie's Secure flag is derived from it, and every link in the UI is built from it. |
ZOOMIES_ENCRYPTION_KEY |
32 random bytes, base64: openssl rand -base64 32. Everything secret in the database is sealed with it. Set it explicitly and store it in the platform's secret manager -- see the volume for what happens if you leave it to be generated. |
ZOOMIES_TRUSTED_PROXIES |
The address range of the platform's ingress proxy. Without it every audit row records the proxy rather than the person, and the login rate limiter throttles every caller as one client. |
nixpacks.toml sets the rest: ZOOMIES_TLS_MODE=off because the platform
holds the certificate, ZOOMIES_STATE_DIR and ZOOMIES_CONFIG_DIR at /data,
JSON logs, and a bind address taken from $PORT when the platform sets one.
Every one of them is an ordinary ZOOMIES_* variable from
Configuration, so anything you set in the platform's own
environment wins over the file's default.
Zoomies will warn at startup that it is listening without TLS. In this deployment that warning is expected, for the same reason it is expected behind Cloudflare: the controller cannot see the proxy from behind it, so it has no way to tell a terminated connection from an origin genuinely exposed in the clear. Behind Cloudflare (or any reverse proxy) is the longer version of this paragraph, and applies here unchanged.
The volume¶
Mount the platform's persistent volume at /data, or set
ZOOMIES_STATE_DIR and ZOOMIES_CONFIG_DIR to wherever it did mount one.
/data holds the SQLite database, the runners' work area, and -- if you did
not supply ZOOMIES_ENCRYPTION_KEY -- the encryption key Zoomies generated on
first start. Without a volume, a redeploy gives you an empty database and a new
key, and the new key cannot decrypt the GitHub App private key the old one
sealed. That failure is silent until the next time Zoomies needs to
authenticate, which is why it is worth getting right before you connect GitHub
rather than after.
A backup is a copy of that directory, plus the encryption key if you kept it somewhere else -- Backup and restore is the whole procedure, and it is the same one here.
Adding a host that can run jobs¶
Once the controller is up, create the first account in the UI, connect
GitHub, then generate a join token (Hosts → Add a host, or
zoomies hosts join-token create --ttl 15m) and run the line it gives you on a
machine with Docker or Podman:
curl -fsSL https://zoomies.sh/install.sh | sh -s -- \
--mode agent \
--controller https://zoomies.example.com \
--join-token zoojoin_...
Agents connect outbound only, so that machine can sit behind NAT with no inbound rule -- including on a laptop, a home server, or a box in the office the PaaS could never reach.
Then make a pool. This path has no zoomies init, so nothing creates one for
you, and nothing runs until one exists: the Pools page, or
Hosts and pools for what the settings mean.
Upgrading¶
Redeploy. The platform rebuilds from the current main, the new binary starts,
and the first start applies any schema migrations -- there is nothing else to
run. Upgrading is what that does to work in flight, and how far
a controller and its agents may drift apart, which matters more here than on a
single VM because the agents are upgraded separately from the controller.
Building the same image yourself¶
It builds exactly what the platform builds, which is the way to check a change
to nixpacks.toml before pushing it.
When not to use this¶
The published image is smaller and starts faster. A Nixpacks image carries the
Go and Node it built with, where ghcr.io/eyupio/zoomies is a static binary on
distroless and nothing else. If your platform can run a prebuilt image, run
that one and skip the build:
That tag is the newest full release. Pin ghcr.io/eyupio/zoomies:vX.Y.Z if you
would rather decide when your platform moves, or run :dev to track main.
Which image tag to run has the rest.
The variables above are the same either way. Nixpacks earns its place when the platform builds from a repository and you would rather not maintain a second answer to "how is this built".