Apps & deploys

From a git push to a live, health-checked release

Every deploy builds off-box, swaps in a new container next to the old one, and only switches traffic once the new release proves healthy. Nothing customer-facing goes through a moment where the app is half-served.

Picking a build strategy

Sproobo builds strictly from your repo, no source ever gets hand-edited server-side:

  • Dockerfile present → it's used. A monorepo Dockerfile that doesn't live at the repo root is still built from the repo root, with --dockerfile apps/web/Dockerfile pointing at it, so COPY instructions can still reach sibling workspace packages.
  • No Dockerfile → Railpack. Railpack auto-detects the framework (Node, Next.js, and friends) at the repo root and builds without you writing a Dockerfile at all.

Either way the image is built in a capped, control-plane-managed builder: CPU, RAM, and disk limits keep a runaway build from ever threatening a customer server, because it was never running there.

pnpm + a standalone build: prune broken symlinks

If you use pnpm and a framework that traces files into its own output directory — Next.js output: 'standalone' is the common case — the tracer can recreate pnpm's fallback link node_modules/.pnpm/node_modules/<pkg> without copying what it points at. That leaves a symlink resolving to nothing, and BuildKit aborts when it checksums that tree to compute a cache key for a COPY:

ERROR: failed to calculate checksum of ref <ref>:
"/app/.next/standalone/node_modules/.pnpm/node_modules/semver": not found
ERROR: failed to build: failed to solve: failed to compute cache key

It is cache-state dependent, which is what makes it confusing: the broken link is there on every build, but it only turns fatal once that step needs a cache key computed against a warm cache. The same commit and the same toolchain can pass cold and fail warm, so a build works for weeks, starts failing with no change to your app, and a cache eviction hides it again.

  • Railpack strategy → already handled. Sproobo prunes broken links from the app directory as the last thing the build step does, so there is nothing for you to add. You'll see prune dangling symlinks (BuildKit cache-key guard) as a step in your build output.
  • Dockerfile strategy → add one line. Your Dockerfile is yours, so Sproobo never rewrites it. Put this at the end of the stage the later COPY --from= reads out of, after your build command:
RUN find . -type l ! -exec test -e {} \; -delete

A link that resolves to nothing cannot be something your app loads at runtime, so this removes nothing load-bearing. Two things about the placement: after your build command, because the build is what creates the broken link in the first place, and after the stage's WORKDIR, because the . is what keeps this working whatever your working directory is called — no path to substitute, and nothing to abort the build if you guessed it wrong.

That form is deliberately the portable one: it works on both GNU findutils (Debian/Ubuntu bases) and BusyBox (alpine bases). On a Debian or Ubuntu base you can use the much faster GNU shorthand instead — roughly 25 ms versus 1.7 s per 8,000 symlinks, since the portable form spawns a process per link:

RUN find . -xtype l -delete

Do not use the -xtype form on an alpine base. BusyBox find has no -xtype, so it exits non-zero and takes your build down with it.

Build-time environment variables

Your app's configured env is also made available during the build, needed for things like a Prisma generate step reading DATABASE_URL, or Next.js inlining NEXT_PUBLIC_* values into the client bundle. How it's delivered depends on the strategy, and the difference matters for secrets:

  • Dockerfile strategy: each entry becomes a --build-arg KEY=VALUE. Build-args land in the image history, so this is not secret-safe. Your own Dockerfile decides what each declared ARG does with the value.
  • Railpack strategy: entries are delivered as BuildKit secret mounts instead, read from the build process's environment and never written into argv or image history.

Changing a build-time value and redeploying always takes effect on the next build, even at the same git commit. Sproobo detects the change and forces the affected build layers to re-run, so a value like NEXT_PUBLIC_API_URL can never get silently stuck on a stale, cached build.

The deploy pipeline

Every deploy, first ever or the thousandth, runs the same blue-green sequence:

01

Build off-box, push to the registry

The image is tagged to the git commit and pushed to Sproobo's S3-backed OCI registry. A Release row snapshots the image tag and resolved config.

02

The agent pulls the image

The server's agent pulls the new image with scoped registry auth.

03

New container starts alongside the old

The new release runs on the internal sproobo-int network while the old container keeps serving all live traffic, untouched.

04

Health gate

The new container is probed at --health-path (default /healthz; a static-image app has no app-level health endpoint, so it passes /) before it ever sees a real request.

05

Atomic proxy switch

Once healthy, the proxy route for the apex and www domains flips to the new container in one caddy validate-gated reload. The last-known-good config is retained.

06

Retire the old container

The previous container stops. Its image tag is kept in the registry: that's what makes rollback instant.

A failed health check never takes down the live app.

If the new container never turns healthy, the old one is still the one serving. The deploy is simply marked failed, with zero customer impact. The only case that auto-rolls-back is a health check that fails after the proxy already switched, and that rollback is automatic and immediate.

Releases & rollback

Every deploy is an immutable, image-tagged Release. Sproobo keeps the last N per app; anything older has its release record dropped, its registry tag garbage-collected, and its image reclaimed from your server, the last two on their own background sweeps (below). New apps default to keeping 3 releases, capped by your plan (Free keeps 1, Pro up to 5, Team and Enterprise up to 10) and the number is adjustable per app within your plan's cap.

Rolling back doesn't rebuild anything; it replays the same blue-green steps against an already-built image:

sproobo rollback <appId> -y

With no --to, the last known-healthy release is picked automatically. Target a specific one (ids come from sproobo deployments) instead:

sproobo rollback <appId> --to <releaseId> -y

Rollback is seconds, not minutes: there's no image to rebuild, just a new container start and a health-gated switch.

Registry cleanup is reconciled, not fire-and-forget

When a release falls out of your keep-N window, Sproobo deletes its image tag from the registry. That delete can fail for reasons that have nothing to do with your app: the registry busy inside its own maintenance window, a transient network error. The Release record is dropped either way, so a tag that outlives its record would be invisible to retention from then on, and a still-tagged image keeps every layer it references from ever being reclaimed.

So that delete isn't trusted to be the last word. A few times a day Sproobo re-reads what the registry actually holds and reconciles it against the surviving Release records: any tag no release still points at is deleted, and the registry's scheduled garbage collection then reclaims the underlying storage. A cleanup that fails costs a few hours of delay, not a permanent leak.

The sweep is deliberately conservative, because deleting on absence of evidence is how a cleanup job turns into an incident. A repository Sproobo holds no release records for at all is treated as foreign and left alone. A repository whose app or preview has a deploy in flight is skipped for that pass: a build pushes its tag before the release record is written, and sweeping inside that window would delete the very image the deploy is about to install.

Reclaiming old images from your server

Retiring a container doesn't take its image with it. Left alone those images accumulate, and on a single box that root disk is also holding your database's data directory, so the failure mode is not a slow one. Sproobo reclaims superseded images from each server roughly every six hours.

The sweep doesn't try to work out which images are garbage, because from the control plane's side that isn't knowable: releases past your keep-N window no longer have records, and a deploy that failed partway pulled an image that never became a release at all. It states what must survive instead, namely the exact references your surviving releases point at, in both tag and digest form. Everything else inside a repository Sproobo pushed to is reclaimable. Cleanup therefore tracks your keep-N setting automatically, and it still reaches images no record was ever kept for. A rollback target is by definition a release that still exists, so it is never a candidate.

This runs on hardware you own, so what it may touch is narrow by construction, and narrower than docker image prune:

  • Only repositories Sproobo itself builds into. Images you pulled or built on the box yourself, and your own base layers, are never candidates at any age. Catalog service images (postgres:17, redis:7) are out of scope for the same reason: they share a repository name with images you could have built yourself. An image carrying even one tag outside those repositories is skipped whole, so nothing can be untagged out from under a name Sproobo doesn't own.
  • An image any container uses is excluded before a single reference is touched, matched on image ID rather than on the name it happens to be filed under, with the daemon's own refusal (which covers stopped containers too) left standing underneath as a second line. Removal is never forced, and an image the daemon declines is stepped over rather than failing the sweep.
  • Anything built in the last hour survives regardless, which covers a deploy that started after the sweep worked out what to keep.

One gap worth knowing about: deleting an app removes its containers, routes and release records, and those records were the sweep's only authority over that repository, so the app's images stay on the box. Reclaiming them belongs to teardown and isn't shipped yet. Until it is, an app deleted to free disk space won't free all of it.

When a deploy fails, the logs come with it

If a deploy fails at or after the health gate, Sproobo automatically captures the failing container's own log tail and attaches it to the deployment. The probe error alone ("never healthy after N attempts") rarely says why; the container's own output usually does. You see it in the deploy view without SSHing into the box to go looking.

Concurrency: one deploy at a time, per app

Each app holds a deploy lock while a deploy is in flight. A second deploy triggered mid-flight queues or is rejected with a clear status rather than racing the first, and because releases are immutable and the swap is atomic, even a near-simultaneous deploy can't leave an app half-served.

Auto-deploy on push

Turn on auto-deploy for an app and a GitHub push to its configured branch builds and deploys automatically, no manual trigger needed. It requires a connected GitHub App installation to receive the webhook, and is editable any time from the app's settings.

Private repositories need that same GitHub App installation to clone at build time. Creating an app from the CLI (or via an AI agent) auto-links the org's installation whose account owns the repo, no separate linking step. In the dashboard's New app flow you pick the installation up front instead, and choose the repo from its list.

The same installation also powers preview deployments: with previews turned on, a pull request against that branch gets its own URL and its own scratch database instead of deploying to production. See Preview deployments.

Creating an app

From the dashboard's New app flow, or the CLI:

sproobo apps create my-app --server <serverId> --repo https://github.com/you/app --domain example.com --dockerfile apps/web/Dockerfile -y

Port defaults to 3000 and branch defaults to the repo's default branch. --dockerfile is only needed for a monorepo Dockerfile that isn't at the repo root; omit it and a repo with no root Dockerfile falls back to Railpack automatically.

Next