Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

DeployLesson

Continuous deployment

A merge should reach the live site without anyone running a deploy, and a commit that fails to build should never take the site down. Continuous deployment gives both; Terrarium does it with one container that watches main, builds each new commit itself, and swaps only a build that worked.

Know continuous deployment already? Skip to In Terrarium.

Outside the box · General

The concept

Continuous deployment ships every change merged to the main branch to the live site automatically, with no release step a person runs. It is only safe if a change that fails to build can't take the site down: build the new version beside the running one, and switch over only when the build succeeds.

The usual shape is an image per release: CI builds the app into a container image, and the server pulls and runs the new image. The other way round is an image that holds only tools. The container clones the repo itself, builds it, serves the result, and checks the branch for new commits. For a Nuxt app, the build is one server folder, .output/, started with node .output/server/index.mjs (Nitro server engine shows what it holds; the Nuxt deployment guide has the details).

Docker Compose describes such a container in one YAML file, with the volume that keeps its files across restarts, and docker compose up starts it. A reverse proxy such as Caddy usually sits in front and passes a domain's HTTPS traffic to it.

Full referenceWikipedia: Continuous deployment

Looking inside

In Terrarium

Terrarium's image, deploy/Dockerfile, holds Node, git, rsync and one script; no Terrarium source is copied in. The script, deploy/entrypoint.sh, clones main into a volume, installs and builds it, copies .output into a separate serve folder and starts Node from there. Then it loops: it fetches main, and when a new commit has arrived it resets the checkout, reinstalls only if the lockfile changed, and builds while the old server keeps answering. Only a build that succeeds replaces the served copy:

deploy/entrypoint.sh ↗153–159as of
153  if build; then
154    publish
155    restart_server
156    log "update applied — now serving $remote_rev"
157  else
158    log "build FAILED for $remote_rev — keeping previous build live (will retry on next commit)"
159  fi

The script deliberately leaves out set -e, so one failing command can't end the loop and the site with it. A failed install also keeps the last good build live, and if Node itself dies, the next pass of the loop starts it again from that build.

deploy/docker-compose.yml runs the one service. It publishes no port on the host; it only exposes Node's port on a shared Docker network, web, where the host's existing Caddy proxy forwards Terrarium's domain to it:

deploy/docker-compose.yml ↗27–35as of
27    expose:
28      # Internal only. The shared Caddy proxies `terrarium.feffef.de` -> terrarium:3000.
29      - "3000"
30    volumes:
⋯      # …
33      - terrarium_app:/app
34    networks:
35      - web

The volume keeps the checkout, the pnpm store and the served build, so a restarted container picks up where it left off. The repo token reaches git through GIT_ASKPASS and is never written into the checkout's config. deploy/README.md covers setting it up on the server.

Why it's built this way

  • Build in the container. Content is built in (Content database), so every merge needs a new build. CI building an image (a packaged app) per release would add a publishing pipeline and redeploy to every edit; building in the container needs neither. The cost: no saved image to roll back to, and each update is a memory-hungry full build. ADR-0011 (docs/adr/0011-poc-self-updating-deploy-container.md).
  • Swap only good builds. A broken commit would reach visitors at once, so the container builds beside the live copy and swaps only on success; the next good commit cures a bad one. Installs follow suit: one failed install once left the next build on old packages, so the script records the last good install. ADR-0011 (docs/adr/0011-poc-self-updating-deploy-container.md).
  • Shared server, no new port. Other apps on the server already hold the host ports and sit behind a shared Caddy proxy, the web server that routes each request to the right app. Terrarium joins Caddy's network and publishes no port, so nothing new opens on the host. ADR-0011 (docs/adr/0011-poc-self-updating-deploy-container.md).
  • Token kept out of the checkout. Cloning a private repo needs a token, and the checkout sits on a volume that outlasts the container. Handing the token to git only when asked keeps it out of the checkout's settings, so a copied volume doesn't leak it. It is also read-only and repo-limited, so a leak can't push. deploy/entrypoint.sh.

What to watch for

  • Merging is deploying. No step sits between a merge and the live site, so the checks before the merge are the only ones. See Merge gate.
  • A change to deploy/ doesn't deploy itself. The container runs the image the operator built; a merged change to the Dockerfile or the script waits until they rebuild it by hand, as deploy/README.md says.
  • The swap is a copy and a restart. The new build is copied over the served folder and Node restarts, so each update has a short gap while the new server starts.
  • Every update is a full build. Content is baked into the build, so even a one-word edit rebuilds the whole site, and a build needs a lot of memory. A build killed for lack of memory just doesn't swap.

Try it in your project

Run your app's production build twice in a row, the second time after breaking one file on purpose. Check that whatever serves your site would still be serving the first build, and if it wouldn't, build into a separate folder and copy only on success.

Where next

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/deploy/self-updating-container