Releases

Release: sync source, build, deploy (config-driven)

stack release is the full deploy in one command: back up → sync source → build images on the VM → compose up. It's driven by a JSON manifest in your project, so the pipeline is generic (no app-specific logic). The compose dir (with its .env) is never in the sync list, so secrets can't be wiped by --delete.

baryovm.release.json:

{
  "localRoot": "~/repos/BaryoClub",
  "remoteRoot": "/home/deploy/baryoclub-src/BaryoClub",
  "sync": ["api", "web"],
  "exclude": ["bin", "obj", "node_modules", ".next", ".git"],
  "builds": [
    { "image": "baryoclub-api:latest", "dockerfile": "deploy/Dockerfile.api", "context": "api" },
    { "image": "baryoclub-web:latest", "dockerfile": "deploy/Dockerfile.web", "context": "web",
      "args": { "NEXT_PUBLIC_API_URL": "" } }
  ]
}
baryovm stack add baryoclub --vm oracle --path …/deploy \
  --release-file ~/repos/BaryoClub/baryovm.release.json \
  --db-container deploy-postgres-1 --db-name baryoclub --env-file .env

baryovm stack release baryoclub                 # backup → sync → build → deploy
baryovm stack release baryoclub --no-backup     # skip the pre-release backup
baryovm stack release baryoclub --no-build      # just sync + compose up
baryovm stack release baryoclub --config ./other.release.json

Per-build options: image, dockerfile, context (both relative to remoteRoot), args (build args), noCache.

Manifest options:

key default what it does
localRoot required local source root, ~ allowed
remoteRoot required absolute destination on the VM
sync required paths under localRoot to rsync. A trailing slash follows rsync's own rule
exclude bin, obj, node_modules, .next, .git rsync-style excludes, honoured by both transports (see below)
builds none images to build on the VM after syncing
sudo false run the release's remote steps as root: the far side of the rsync, the image builds, preDeploy, postDeploy and the closing compose up. A stack registered --sudo turns this on for its own release and a manifest cannot turn it back off
noCompose false skip docker compose up at the end
postDeploy none commands to run on the VM after syncing and building
verify none commands run on the VM after postDeploy that decide whether the release worked. Every one must exit zero
verifyAttempts 5 maximum number of times the whole verify plan may run, not retries after a first run
verifyDelaySeconds 3 wait between attempts

Moving a hand-run container into a stack

A VM you already have is usually running containers started with docker run. If your compose file gives a service the same container_name, compose cannot create it while the old container exists. stack release, stack deploy and stack update check this before they back up, sync, build or pull, and stop with the container's name and the command that frees it:

container name "umbraco-pwa-demo" is held by a container compose does not manage, probably started with docker run: check it is safe to remove, then run `docker rm -f 'umbraco-pwa-demo'` on the VM and try again

BaryoVM never removes that container itself. Check first that its data lives in a volume, not in the container. If the old container used a named volume, declare it external: true in the compose file. Otherwise compose creates a new, empty, project-prefixed volume and the app starts with no data.

volumes:
  umbraco-pwa-demo-data:
    external: true

Verifying a release

A release that reports success without asking the running site anything cannot tell a working deploy from one that landed and serves the wrong thing. That is not hypothetical: a demo site served its framework's "welcome, now install me" screen through two releases, a perfectly valid 200 at the exact URL every listing pointed at, and every command in the deploy had succeeded.

So after postDeploy, stack release runs a verification plan and fails the release if it does not pass:

  • If the manifest sets verify, those commands run. Point this at a check script the repository already has rather than reimplementing one here.
  • Otherwise, if the stack has a healthUrl, it becomes a curl --fail probe.
  • If neither is set, the release warns that it verified nothing and still succeeds. An interactive release is not an unattended update, so this is a warning rather than a refusal.

The probe runs on the VM, not locally. A healthUrl is usually loopback, which from your laptop either fails or, worse, reaches something local and passes.

One rule worth carrying into any check script: a "must not contain" assertion only means something after a real body has arrived. An empty response, a 502 and a DNS failure all satisfy a negative grep, so assert the status and a minimum size first.

Sync transports and excludes

With local rsync on your PATH, sync entries go through rsync. Without it they go as a tar stream over the SSH session (Linux hosts), and baryovm doctor names which one a release will use. The stream is written with Go's archive/tar straight into tar -x on the host, never held in memory. Once the extract has exited cleanly, files that are on the host and not local are removed, so the result matches rsync --delete. Two differences: there is no delta transfer, and files arrive owned by the SSH user (root under sudo) rather than with your local owner. Setuid and setgid bits are dropped.

exclude follows rsync's matching under both transports:

  • A pattern with no / matches a name at any depth: .env, *.map.
  • A pattern with a / matches the end of the path at any depth: secrets/.env also excludes a/secrets/.env.
  • A leading / anchors to the transfer root: /secrets/.env matches only the top level. With a sync entry like "out" (no trailing slash) rsync's paths start with out/, so anchor as /out/secrets/.env.
  • * and ? stop at /, ** crosses it. A leading **/ also matches zero directories, so **/.env excludes the top-level .env too. A trailing / matches directories only.
  • Excluded paths are not sent, are not deleted from the host, and a host directory that holds one is not deleted either.

The tar transport supports only that subset. A pattern using character classes ([ab]), a backslash, ***, a leading // or a filter prefix (!, + , - ) is refused with an error before anything touches the host; simplify it or install rsync.

Pruning is confined to the entry's destination directory. It is refused when that directory, or one between remoteRoot and it, is a symlink on the host. Each removal re-checks, on the host and just before the delete, that the path's parent still resolves inside the release dir. A window of an instant remains between that check and the delete, which POSIX tools cannot close, so do not release into a directory that other users can write to. A symlink to a single file cannot be a sync entry under tar.

The trailing slash on sync entries

This is rsync's rule, kept rather than invented, and it decides where files land:

  • "out" copies the directory, giving remoteRoot/out/...
  • "out/" copies its contents, giving remoteRoot/...

Get it wrong on a webroot and --delete empties the directory the site is served from while putting the site one level down. Check with a dry run before the first release of a new stack:

rsync -az --delete --dry-run --itemize-changes --rsync-path="sudo -n rsync" \
  -e "ssh -i ~/.ssh/your_key" ~/repos/site/out/ user@host:/var/www/site/

When a release refuses to sync the compose directory

The compose directory is the stack's --path, and it holds the .env. rsync --delete removes any file on the VM that is not in the local source, so a sync that reaches that directory would delete the .env. stack release checks for this after the pre-release backup and before the first sync entry runs. It stops if any entry lands on the compose directory or on a directory above it.

Where an entry lands follows the trailing slash rule above. With "remoteRoot": "/opt/app" and a stack registered with --path /opt/app/deploy:

  • "deploy" lands on /opt/app/deploy and is refused.
  • "out/" lands on /opt/app, which contains deploy, and is refused.
  • "api", "web" and "deploy-assets" are allowed. A name that only starts with deploy is not inside it.

The first case fails with:

sync entry "deploy" lands on /opt/app/deploy, which contains the compose directory /opt/app/deploy: rsync --delete would remove its .env. Sync the application directories individually instead of the root that holds them

Under -o json the same text is the envelope's error, with "action": "stack release".

To fix it, list the directories the release needs one by one, leaving the compose directory out: "sync": ["api", "web"] rather than "sync": ["out/"]. A remoteRoot that does not contain the compose directory passes too.

What the check does not do:

  • It skips a manifest with "noCompose": true. A static site's stack directory is its webroot, so it is the same path as remoteRoot on purpose and there is no .env to protect.
  • It compares the paths as written, after trimming spaces and one trailing slash. It does not resolve symlinks or .., and it does not look at the VM, so a destination that reaches the compose directory through a symlink is not caught.
  • It is the same with or without --sudo.