Job kinds

The deploy-queue job kinds the control plane enqueues and the agent claims — what each does, the JobSpec fields it uses, how executeJob runs it, and how secrets are delivered.

Job kinds

The deploy queue is how the control plane makes a Node do work without ever opening a socket to it. The control plane enqueues a job; the agent daemon claims pending jobs on its heartbeat, runs OUR build → run → route engine, and reports state + streams logs back. The agent is pull-only — no inbound connection to the box.

This page enumerates the real job kinds, the JobSpec fields each uses, and how the agent executes them. It is grounded in three files:

For the daemon loop that drives this, see CLI reference §hostssh agent.

The queue lifecycle

A job moves through the states defined in jobs.ts (JobState): pending → claimed → running → succeeded | failed | cancelled.

  1. Enqueue. The control plane calls enqueueJob(licenseKey, spec, { kind, fingerprint, deploymentId }). validateJobSpecForKind runs first — a bad spec throws at enqueue time instead of failing mid-deploy. A job may be pinned to one host fingerprint or left unpinned for any Node on the license.
  2. Claim. The daemon polls POST /v1/jobs/claim (jobs.Claim) every ~15s. claimJob atomically claims the oldest pending job (or reclaims a stale claim past the LEASE_MS = 5-minute lease) using FOR UPDATE SKIP LOCKED.
  3. Run. runJob reports running, then calls executeJob under a 30-minute context (jobTimeout). Output is batched to POST /v1/jobs/{id}/logs (AppendLogs); streaming logs also keeps the lease fresh.
  4. Cancel. While running, the agent polls GET /v1/jobs/{id}/cancel (CheckCancel) every ~3s. An operator cancel sets cancel_requested; on observing it the agent cancels the context (killing the build/run subprocess) and reports cancelled (distinct from failed).
  5. Report. ReportStatus writes the terminal state via POST /v1/jobs/{id}/status. Both status and log writes carry the agent's fingerprint, which fences the write to the claim owner so a late report from a dead/reclaimed agent can't clobber a re-run.

All jobs run one at a time, serially in the daemon's job goroutine — no overlapping deploys.

The kinds

The control-plane JobKind union (jobs.ts) is the authoritative set of kinds the queue enqueues:

deploy · redeploy · stop · remove · db · restore · firewall · prune · expose · unexpose

The agent's executeJob switch (cli.go) matches these, and additionally accepts a few internal aliases (provision-dbdb, load-snapshotrestore, hardenfirewall). A kind the build doesn't handle returns job kind %q is not supported in this build.

KindWhat the agent doesExecutor call
deploy / redeployBuild → run → route the app.deploy.New().Deploy(...)
db (provision-db)Provision a managed database container on this Node.database.Provision(...)
restore (load-snapshot)Load a registered snapshot onto this node via the clone engine, rewriting the IP.engine.RestoreTo(...)
firewall (harden)Apply the host firewall posture.firewall.Apply(...)
stopStop the app's container.runtime.NewDocker().Stop(...)
removeRemove the app's container.runtime.NewDocker().Remove(...)
pruneReclaim Docker disk (retention + dangling + build-cache GC).reaper.Run / reaper.Preview
exposePublish a Node-local port to the internet via a cloudflared tunnel.expose.Expose(...)
unexposeWithdraw a public exposure.expose.Unexpose(...)

Recovery-view honesty note. jobs.ts also reads rows of kind clone and migrate (in listRecoveryJobs) to render the Recovery page, and those kinds appear in the RecoveryKind type. But they are not in the enqueuing JobKind union and not in the agent's executeJob switch — this build neither enqueues nor executes clone/migrate jobs. Treat them as reserved.


deploy / redeploy

Build (HostPack/Dockerfile) or run a prebuilt image, then route it through Traefik. redeploy is identical — same executor arm. The workhorse kind.

JobSpec fields used (mapped 1:1 into deploy.Spec in executeJob):

FieldMeaning
appApp name (required; validated).
sourceRepo path / git URL / image ref to build or run.
refGit branch/tag (default branch if omitted).
domain, port, readinessPathPublic routing + health probe.
networkDocker network shared with the proxy (default hostssh).
builderhostpack or image (validated); Dockerfile build is auto-detected/selectable in the agent.
imageTag, imageLimitMbImage tag; fail-before-run size cap (≤ MAX_IMAGE_BUDGET_MB).
migrateCmdRelease-phase command run before the app starts.
verifyCmdDrift gate — pre-flight check after migrate; non-zero exit fails the deploy.
commandRun instead of the image's default CMD (e.g. a worker).
slot, cpus, memoryMb, pidsLimitSlot binding + resolved resource caps (from slotCaps(size)); the agent applies docker --cpus/--memory/--pids-limit + a hostssh.slot label.
envPlain (non-secret) env KEY=VALUE map.
generateSecrets, secretSourcesSecret keys/store names only — see Secret delivery.

db (provision-db)

One-click managed database. The database name is the job's app field; engine/version/network come from the spec. Postgres provisions full pgvector by default.

JobSpec fields used: app (DB name), engine (postgres | mariadb | redis, validated; defaults to postgres), version (image tag override), network (default hostssh). Executes database.Provision(...).


restore (load-snapshot)

The slot-snapshot load verb: restore a registered snapshot onto this node via the proven clone engine, streaming progress to the job log. Default is a non-destructive clone (engine restores to /var/restore + a clone-tagged PG container). The destructive --rewrite-coolify is opt-in; --activate-wg is deliberately not exposed via a job (replacing a live source box is an out-of-band decision).

JobSpec fields used (assembled into engine.RestoreTo flags): snapshot (restic snapshot id or latest), tag (capture tag), oldIp (source IP, rewritten away), newIp (this node's IP, rewritten in), pgImage (postgres image for the restored DB), rewriteCoolify (off by default). app is a human label for the load.


firewall (harden)

One-click hardening applied from the dashboard.

JobSpec fields used: sshPort (SSH port to keep open; 0 → the agent uses its default), allowTcp (inbound TCP; defaults to [80, 443] when empty), allowUdp (e.g. 51820 for WireGuard), firewallReset (reset rules before applying). Executes firewall.Apply(...).


stop / remove

Container lifecycle. Both take only app. stopruntime.NewDocker().Stop(app); removeruntime.NewDocker().Remove(app).


prune

Docker disk hygiene: keep last-N app images, drop dangling images, bound the build cache. Volumes are never pruned.

JobSpec fields used: keepLast, builderGb, builderUntilHours (each override the env-derived reaper.PolicyFromEnv() defaults when > 0), dryRun (→ reaper.Preview, else reaper.Run). The control plane surfaces recent prune jobs + reclaimed-GB in the Fleet drawer (listPruneJobs, parseReclaimedGb).


expose / unexpose

Publish (or withdraw) a Node-local port to the internet through the operator's named cloudflared tunnel (HOSTSSH_CF_TUNNEL, read from the agent's env).

JobSpec fields used: targetPort (the local port, required + validated 1–65535 for expose), publicHostname (validated as a real DNS hostname before it reaches an agent), exposureId (ties the job back to the persisted exposures row the agent's report updates). Executes expose.Expose / expose.Unexpose.


The JobSpec

JobSpec (TS in jobs.ts) and Spec (Go in jobs.go) are the same shape on both sides of the wire. app is the only universally required field; the rest are optional and interpreted per kind. validateJobSpecForKind enforces the contract at enqueue time — a summary of what it checks:

FieldRule
appRequired, non-empty, ≤ 200 chars.
port, targetPortInteger 1–65535 when present (targetPort required for expose).
readinessPathMust start with /, no whitespace/control chars, ≤ 200 chars, valid HTTP path.
builderhostpack or image; image needs an imageTag or source.
imageLimitMbPositive integer ≤ MAX_IMAGE_BUDGET_MB.
network^[A-Za-z0-9._-]{1,128}$.
enginepostgres | mariadb | redis.
publicHostnameValid multi-label DNS hostname (lowercase, no shell/whitespace) for expose/unexpose.
keepLast / builderGb / builderUntilHoursNonnegative integers ≤ 100 / 500 / 8760.
generateSecretsArray of ≤ 100 valid env keys (^[A-Za-z_][A-Za-z0-9_]{0,255}$).
secretSourcesArray of ≤ 20 valid store names (^[a-z0-9][a-z0-9-]{0,62}$).

Secret delivery

Secret values never travel in the JobSpec (which is persisted in the jobs table) — only keys and store names do. There are three delivery fields, mirrored in jobs.ts, jobs.go, and consumed by executeJob's deploy arm:

FieldCarriesWhere it lives
spec.generateSecretsSecret env keys the agent mints on-host and injects (template secrets marked generate:true). Names only.In the JobSpec — safe, no values.
spec.secretSourcesOther on-host secret store names to fold into this app's env (e.g. a managed DB's name so the app gets its DB creds). Names only.In the JobSpec — safe, no values.
job.secretsUser-entered secret values, delivered with the claim response — decrypted from at-rest ciphertext, carried over pinned TLS, and not persisted in the jobs table.Attached per claim-response (Job.Secrets in jobs.go), passed as DeliverSecrets to deploy.Spec.

The deploy executor arm folds all three together: GenerateSecrets (mint on host), SecretSources (fold in linked stores), and DeliverSecrets (the values that rode the claim). Because a DB dump of the jobs table never contains plaintext, a leaked queue store is not leaked secrets.

For the full custody model and the generate / sealed / linked delivery paths, see Environment variables & secrets.


See also: CLI reference · Agent protocol · API & CLI · Environment variables & secrets