Skip to content

Latest commit

 

History

History
1730 lines (1465 loc) · 96.5 KB

deployment.md

File metadata and controls

1730 lines (1465 loc) · 96.5 KB
title Deployment
description Deploy a git revision to a remote with govard — the neutral task pipeline, framework recipes, hooks, the two-job CI shape with artifacts, rollback, and the sandbox that rehearses the whole thing on your machine.

Deployment

govard deploy publishes one git revision to one remote environment. It needs only SSH and rsync — no Docker, no local PHP, no other deploy tool — which is what lets the same command run on a laptop and in a CI job.

govard deploy staging --revision <sha>   # deploy an exact commit
govard deploy staging                    # ... or the local HEAD
govard deploy plan staging               # print the plan, connect nowhere
govard deploy check staging              # preflight and report what the target implies

The target is a remote from .govard.yml. The branch, repository, deploy path and publish strategy default from the project deploy: block; a remote overrides them — either at remote level or under its own deploy: block, but not both with different values (that is a configuration error naming both places). A deploy_path set nowhere is probed from the target: govard adopts the layout already there — releases/, shared/, .dep/ or a current symlink — only when exactly one candidate matches, saying which. No layout or several is a configuration error naming what was probed.

This page describes the engine and every lever it has. For worked configurations — a Luma store, a Hyvä storefront, several themes and store views, developer versus production mode, and a symlinked versus a real webroot — see Deployment case studies.

Setting up a Magento project

Four things have to exist before the first deploy: a target that already serves the project, a remote govard can reach, credentials for whatever the release installs, and a decision about how a release becomes live. Each one fails with its own message, and no step infers another.

1. The target already runs the application

govard deploy publishes into a running installation; it does not create one. For a Magento project the target needs:

  • an installed application — shared/app/etc/env.php, the file deploy:shared links into every release, naming a database, a cache backend and a session handler the target can reach;
  • the database behind it, with the store configuration in it. setup:static-content:deploy stops at The default website isn't defined when the store tables are missing, and migration cannot run at all;
  • a supported search engine where the project uses one. setup:upgrade refuses Magento's MySQL fallback outright — Your current search engine, 'MySQL', is not supported — so an ElasticSuite or OpenSearch project needs its cluster reachable before the first deploy, not after;
  • SSH for the deploy user with rsync on the target, and write access to the layout directory;
  • credentials for private sources, on the target (next section).

A target that has never run the application is a target no recipe can publish to. The sandbox is the place to find that out before a server is involved.

2. The remote

remotes:
  staging:
    host: m2-staging.example.com
    user: m2-staging
    port: 22
    path: /home/m2-staging/public_html      # the docroot that is served
    auth:
      method: keyfile
      key_path: ~/.ssh/staging
    deploy:
      branch: main                          # optional; defaults to the local HEAD
      deploy_path: /home/m2-staging/.deployer  # releases/, shared/, .dep/ live here
      settings:
        php_bin: php8.3
        composer_bin: composer
        php_version: "8.3"                  # what the target runs, not a preference
        owner: m2-staging:m2-staging
        writable_mode: chmod+chown
      verify:
        url: https://staging.example.com/

php_bin and composer_bin are command words, not one word: php -d memory_limit=-1 and docker exec app php work, the probes in deploy:check run exactly the words the recipe runs, and shell syntax in the value is inert. A binary path containing a space is not supported — split words cannot tell it from a wrapper with an argument.

A leading ~/ is the one piece of shell syntax that is kept, because it names a real configuration: composer_bin: "php ~/composer.phar" is common on shared hosting. It is rendered so the shell expands it, not govard. Anything else has to be written absolutely: $HOME/... is literal, and an assignment prefix needs env — php_bin: "env PHP_INI_SCAN_DIR=/x php".

php_version is a gate: deploy:check runs <php_bin> -r 'echo PHP_VERSION;' on the target and refuses the deploy when the series does not match, because a release built by the wrong interpreter fails later and says less about why. deploy:check also reports the layout it found, the publish strategy that implies, the free space at the deploy path, and whether the repository is reachable from the target — run it before the first deploy, and read it as the answer to "is this remote ready".

3. What the Magento recipe already does

The recipe fills the framework tasks and ships defaults for the shared layout, so a stock project needs no deploy.settings at all:

Setting Default
shared_files app/etc/env.php, var/.maintenance.ip
shared_dirs var/log, var/report, var/session, var/backups, var/tmp, pub/media, pub/sitemap, pub/static/_cache
writable_dirs var, pub/static, pub/media, generated, app/etc
sync_paths vendor, generated, pub/static/adminhtml, pub/static/frontend — in-place publishing only

Override what your project differs on, and nothing else:

  • frontend: frontend_dir (one or more Hyvä theme paths) and frontend_command (default npm ci && npm run build) — see Frontend builds;
  • static content: static_jobs, static_content_locales, static_deploy_options, the adminhtml/frontend split, and the theme lists — see The static content split and Multiple stores, websites and themes;
  • mode: mage_mode (developer skips static content deployment) — see Developer and production mode;
  • workers: worker_control: true runs cron:remove/queue:consumers:restart around the deploy and restores them after — see the pipeline table;
  • opcache: runtime_reload_command after the cache flush, for a target whose opcache is reachable from the deploying user — see Caches, opcache and the symlink swap;
  • ownership: owner, writable_mode (chmod, chown, chmod+chown, acl, skip) and writable_permissions — see Permissions and ownership.

Every key either recipe declares, with its default and its shape, is tabulated in Deployment case studies § Reference.

4. Where Composer credentials come from

Three routes, in the order Composer resolves them, and the order matters:

Route How it reaches the build
COMPOSER_AUTH in the deploying environment forwarded to build:vendors on the command's standard input (never argv, never the log) and overrides every file on the target
auth.json in the project materialised into the release by deploy:code, so Composer reads it from the release root — this is what a project that commits one relies on
shared/auth.json on the target only read if the release has an auth.json pointing at it, which means listing auth.json in deploy.settings.shared_files

Because COMPOSER_AUTH wins, a machine-wide token that is wrong for the project is worse than none at all: it overrides the project's own working auth.json and the build fails with the source's authentication error, looking exactly like a project that has no credentials. Export the project's credentials for the deploy, or export nothing and let the target's own file answer.

A git-type package is a different problem: Composer clones it over SSH, so the target needs a key and a known_hosts entry for the host. No environment variable carries those.

govard deploy check says which route it found — COMPOSER_AUTH is set for this run, shared/auth.json exists on the target, or a warning that a project declaring private repositories has no credentials available.

5. How a release becomes live

auto (the default) resolves from the target: an absent or symlinked docroot publishes by an atomic rename of a current symlink, an existing docroot that is a real checkout is updated in place. Pick one explicitly with publish: symlink or publish: in_place on the remote when the target is ambiguous — for example its docroot is a directory that is not a checkout.

In place, the release is reset into the docroot and only sync_paths is copied, so that list has to name what the release built; see In-place publishing needs sync_paths. In both strategies the maintenance window opens on the release that is being served, and for a symlink it closes before the swap.

Either way, deploy:shared runs before anything is built, and it is what makes a release read the state that outlives it: every shared_files/shared_dirs entry that exists under <deploy_path>/shared/ is linked into the release. A directory the release already carries is replaced by that link rather than left in place — a checkout that tracks a placeholder inside a shared directory materialises the directory, and a release directory is disposable by construction: this step runs before any build step writes there, and an artifact carries no shared path at all. The live application is the case that must not lose data, and it is treated the other way: an in-place docroot adopts a shared directory it owns into shared/ on the first deploy and then reads it through a link, while a directory that exists in both places is left where it is.

Two guards keep that replacement to placeholders. An entry nested in another shared directory (pub/media/catalog under pub/media) is not linked on its own: it is already reached through the outer link, and inside that link the directory is the shared data itself. And a release directory whose physical path lies outside the release (a tracked or hand-made symlinked parent) is never deleted; the step fails and names it instead.

6. The first deploy

govard deploy plan staging     # the entire task list, connecting nowhere
govard deploy check staging    # preflight: connectivity, layout, permissions, php, locked php floor, disk, local checkout
govard deploy staging --yes    # ... or --remote staging

It also reads the local checkout the way the deploy does, because those two answers come from the files on disk rather than from the target. A .gitmodules in the project root is refused — git archive cannot carry submodule content, and a release with empty submodule directories would look successful — and a composer.json that declares a non-packagist repository while no credential is available is reported as a warning. Both come from the same prepare preflight the deploy itself runs, so the .gitmodules refusal it reports is the refusal the deploy makes.

Findings print before the Target ... is deployable line: notes start with -, and warnings are lines starting with ! warning:, so a script can grep warning:; exit codes are unchanged.

deploy check leaves nothing behind on the target, local or remote: its writability probe creates no path at all — an absent deploy_path is probed at the nearest existing parent on the target itself, and on a local target the note names the parent that answered — and the mv -T probe it runs on a symlink target creates a .dep scratch directory there and removes it again. On a fresh host the probe also creates the missing levels of the deploy path above .dep and removes them again, stopping at the nearest ancestor that already existed; a level that holds anything of its own stays. So the promise is about the end state, not about never issuing a creating syscall: the atomic-rename probe is create-then-remove by design, which is what lets it answer for a fresh host. A remote call gets no such note — an exit code cannot tell an absent path from an unwritable one, so the note would be a claim about a machine govard cannot see. The probe runs through the same runner a deploy uses, so it travels to the machine that owns the path — which is also why a fresh host passes the check instead of failing it. The check never looks at the deploy lock: that is deploy:lock's job, in a real deploy.

The notes the preflight collects print as they are found, one per line, before the Target ... is deployable header: - marks a fact and ! a warning. A real deploy prints the same notes in the output of its deploy:check step, on stderr under --json so stdout stays one document.

Watch it with --verbose, which streams each command's own output under its task and never batches it. When a step fails the run says which step, and what to do next: a failure after the maintenance window opened keeps the lock and points at govard deploy --remote staging --resume, while a failure before it releases the lock and points at a plain retry. Exit codes are the CLI contract (0 success, 1 execution, 2 usage, 3 missing capability, 4 configuration), and --json emits one document instead of the timeline.

7. Rehearse it on this machine first

govard sandbox up --profile full --php 8.4   # a real target, on loopback
# the seed is automatic (database, media, env.php); provide credentials + a search engine where the project needs one
govard deploy --remote sandbox --yes
govard sandbox down --purge

The sandbox is a production deploy pointed at a container — same SSH, same mirror, same recipe — so a failure there is a failure you would have met on the server, without a server. It needs the same application prerequisites as any target; the section below says what each failure means, and Deployment case studies gives the profile and --docroot shape each kind of project needs.

Laravel, Symfony and WordPress

The engine is framework-neutral; a recipe is what fills the pipeline's neutral steps with the commands one application needs. Everything above — releases, publishing strategies, rollback, deploy check, the sandbox — applies to these three unchanged. What follows is only what each recipe adds, and the three places where it does not behave like the Magento one.

Task Laravel Symfony WordPress
build:vendors composer install --no-dev --optimize-autoloader same, plus --no-scripts only when composer.json exists
build:assets — assets:install public --symlink --relative (on the target) —
build:frontend frontend_dir × frontend_command same same
app:configure artisan storage:link — —
db:migrate artisan migrate --force doctrine:migrations:migrate --allow-no-migration wp core update-db
maintenance:enable / disable artisan down / up empty two files in the served path
app:cache:flush artisan optimize:clear then optimize cache:clear --no-warmup then cache:warmup wp cache flush + wp rewrite flush --hard
app:workers:pause artisan queue:restart (+ horizon:terminate) messenger:stop-workers —
db:backup / restore — — wp db export / wp db import
deploy:verify check artisan db:show dbal:run-sql "SELECT 1" wp core is-installed

Shared state, by framework:

Framework shared_files shared_dirs sync_paths (in-place only)
Laravel .env storage vendor, public/build
Symfony .env.local var/log vendor, public/bundles
WordPress wp-config.php wp-content/uploads vendor

Where the build steps run, and what an artifact carries

In --build=server every step runs on the target. In --build=artifact the build job runs govard deploy build on its own machine, and the target skips the five build tasks the artifact replaces — except the ones a recipe marks needs the application (NeedsApplication), which no build machine can produce because they read the installed application's own configuration:

Recipe Build step that still runs on the target in artifact mode What the artifact has to carry
Magento 2 build:assets (setup:static-content:deploy) vendor/, generated/, the frontend build output
Laravel none vendor/, the frontend build output (public/build)
Symfony build:assets (assets:install public --symlink --relative) vendor/, the frontend build output
WordPress none vendor/ when the project has one, the frontend build output

No framework cache belongs in the artifact, and none of these recipes can put one there: app:cache:flush is a publish-stage step in all four, so it always runs on the target, where the environment the cache bakes in actually holds. It runs against the served application — {{current_path}} — not against the release being built: a symlink activation makes the two the same directory, while an in-place docroot is a separate real directory, and a flush in the release would clear a cache nothing reads. Magento's worker steps follow the same rule, because cron:install writes the application's absolute path into the crontab.

The steps these recipes leave empty

An empty step is reported as skipped, never as a failure, and each one is a decision rather than an omission. build:compile and build:patches are empty for all three: none of them does ahead-of-time code generation, and none has a patch step. app:workers:resume is empty for Laravel and Symfony — queue:restart and messenger:stop-workers are the whole signal, and restarting the workers is the process manager's job — while WordPress has no worker tasks at all. Symfony leaves both maintenance steps and app:configure empty; Laravel and Symfony additionally leave db:backup empty, which is why --db-backup on them is refused instead of silently skipped.

What each recipe asks the sandbox for {#sandbox-recipe-defaults}

These are the lists a recipe declares — the application's default, not a policy. A project overrides any of them with deploy.settings.sandbox_*, and each list replaces the recipe's rather than extending it:

Recipe sandbox_packages sandbox_extensions sandbox_services sandbox_tools
Magento 2 libxslt1-dev, libzip-dev, libpng-dev, libjpeg-dev, libfreetype6-dev, default-mysql-client bcmath, curl, gd, intl, mysql, soap, sockets, xsl, zip mariadb, redis-server —
Laravel default-mysql-client bcmath, curl, gd, intl, mbstring, mysql, sqlite3, xml, zip mariadb, redis-server —
Symfony default-mysql-client intl, mysql, mbstring, xml, curl, zip mariadb, redis-server —
WordPress default-mysql-client mysqli, curl, gd, intl, mbstring, xml, zip mariadb, redis-server wp-cli

A service name from the engine's table carries the package that provides it, so naming postgresql is enough to install and start it; a service the image cannot start is named on container start rather than skipped in silence. sandbox_tools accepts only the binaries the engine has an install recipe for — today that is wp-cli — and an unknown name is refused when the image is rendered, not when it fails to build.

No recipe asks for a search service, so the sandbox ships none: every rehearsal before the migrate path was green on probe-exit-0 skips, and the first real setup:upgrade on a search-dependent project fails when the engine cannot be reached (ElasticSuite validation, then any recurring step that pings the cluster). To rehearse the migrate path on such a project, either disable the search-dependent modules in a scratch release before the run, or point the sandbox at a reachable engine for the rehearsal — join the container to the origin env's network so its search hostname resolves, override the engine's server hostname to it, and revert both afterwards. A search service inside the sandbox image is future work, not a rejected idea: it needs its own version matrix against whatever the origin runs.

The three places these recipes differ from Magento's

Symfony has no maintenance task. Symfony has no core mechanism for it, so maintenance:enable and maintenance:disable stay empty and the engine reports them as skipped. db:migrate therefore runs against a live site. If a project needs a window, that is a project hook:

deploy:
  hooks:
    - { name: down, on: "maintenance:enable", position: after, order: 10, run: "touch {{current_path}}/maintenance.lock" }
    - { name: up,   on: "maintenance:disable", position: after, order: 10, run: "rm -f {{current_path}}/maintenance.lock" }

WordPress maintenance is written, not commanded. WordPress's own mechanism is two files: .maintenance in the docroot, which wp_is_maintenance_mode() looks for, and the wp-content/maintenance.php drop-in, which wp_maintenance() serves with a 503. wp_is_maintenance_mode() treats a flag older than ten minutes as expired, so the recipe writes $upgrading = time() + 86400: writing the value WordPress itself writes would reopen the site in the middle of a window longer than ten minutes, and put traffic back on a half-migrated database if the deploy failed at minute nine. The drop-in carries a marker and only a marked file is removed, so a project that ships its own maintenance page keeps it.

Under the symlink strategy these files are written into whichever release is live when the window opens, so after the swap they belong to the previous release — the new one is served normally, which is correct, but a rollback to that previous release would find it still in maintenance. maintenance:disable removes them from the path it can address, and this is the manual remedy:

rm -f {{current_path}}/.maintenance {{current_path}}/wp-content/maintenance.php

db:backup is opt-in per framework. It defaults to off everywhere. Magento has it through a plain mariadb-dump/mysqldump of the application's database (stock Magento ships setup:backup disabled) and WordPress has it through wp db export/import, or the same plain dump when the target has no wp-cli; Laravel and Symfony have no dump command in their recipes. Turning --db-backup on for a framework without one is refused before the run starts (exit 4), with a message naming the recipe and the remedy — deliberately, because silently skipping it would let an operator believe a backup exists immediately before a destructive db:migrate. A project that wants one adds a hook:

deploy:
  hooks:
    - name: dump
      on: "db:backup"
      position: after
      order: 10
      run: "mysqldump --single-transaction \"$DATABASE_URL\" > {{shared_path}}/backups/manual.sql"

Framework-specific notes

Magento. The cache flush and the worker steps act on the served application. cron:install records the application's absolute path, and cron:remove deletes only the block keyed by the install root it runs in — BP, the resolved dirname(__DIR__) of the running bin/magento, hashed into #~ MAGENTO START <sha256(install root)>. app:workers:pause therefore has to run where cron:install last ran, and worker_control: true uses queue:consumers:restart (the poison pill consumers check between messages) because Magento 2.4 has no queue:consumers:stop.

bin/magento cache:flush is not a sweep: the 2.4.9 file cache is a Symfony pool whose entries are cleaned through a tag index, so an entry whose index row is gone is unreachable while its file stays valid — the command exits 0, removes nothing, and the served application keeps answering from it. The step therefore drops whatever is left under the served var/cache and var/page_cache after the framework's flush and reports how many entries it removed. Entries are removed, never the directories (a recreated var/cache may not be writable by the web-server user), var/session and the build output are untouched, and a purge that cannot remove entries prints a warning instead of failing the deploy, and a cache directory it cannot even read is named rather than passing silently. Measured on a production Magento 2 target: a 473ms flush that removed nothing left a merged layout naming a class the release had removed, and the site answered HTTP 500 after every deploy until the file cache was deleted by hand.

That keying has one consequence worth checking on a target that already deployed with worker_control: true: earlier releases ran both steps in the release directory, so each deploy installed its own block. The step is now run against the served application, so a block is removed and rewritten rather than added — but the historical blocks remain, one per release, each running cron:run every minute and pointing at a directory deploy:cleanup will prune. Check once:

crontab -l | grep -A1 '#~ MAGENTO'
readlink -f ~/public_html            # what `current` resolves to, on a symlink target

Keep the single block whose command line names the application the web server actually runs — the docroot itself on an in-place target, or the release readlink -f current prints on a symlink target (there, BP resolves through the symlink, so the block names a releases/<n> path, never current). Delete the others with crontab -e; the next deploy writes a fresh block for the application it installs. Do not delete every block: on a symlink target the live one is a release path, and removing it stops cron until the next deploy.

Laravel. storage is shared rather than merely writable because the maintenance flag lives at storage/framework/down: a shared directory is what carries it across the release swap. The framework caches are built in app:cache:flush, on the target, never at build time — artisan optimize writes bootstrap/cache/config.php, and once that file exists the process environment no longer overrides .env, so a cache built elsewhere would carry another machine's configuration to production. worker_control sends queue:restart, which exits 0 with any cache store, so a project whose cache cannot carry the signal needs a persistent one for the step to mean anything. It is a restart rather than a pause: the supervisor that owns the worker brings it straight back, on the new code, so the step shortens the overlap with db:migrate rather than removing it. The app check prefers artisan db:show and falls back to migrate:status on Laravel 10 and older, where db:show does not exist; the fallback exits 1 on a project that has no migrations table yet, which is a healthy zero-migration project.

Symfony. Composer's auto-scripts runs cache:clear and assets:install, which is right for a developer and wrong for a deploy: both have to happen where the application lives, so build:vendors passes --no-scripts and build:assets runs assets:install on the target with --relative (the links it writes then resolve from wherever the docroot ends up, which an in-place deploy needs). doctrine:migrations:migrate takes --allow-no-migration, because a project with an empty migrations directory is a healthy project. var/cache is deliberately not shared: the compiled container belongs to one release and one environment. The Messenger pause runs against the served application for the same reason: messenger:stop-workers writes its signal into a cache pool (cache.app, a filesystem adapter under var/cache/<env> by default), which no release shares — from the release, the signal would sit where the running consumers never look. symfony_env (default prod) decides the environment the deploy runs under, because a committed .env saying APP_ENV=dev is a development default rather than an instruction to production; the value is not validated, so a typo builds the wrong cache directory. The app check prefers dbal:run-sql and falls back to doctrine:query:sql, chosen by asking bin/console rather than by guessing a DoctrineBundle version.

WordPress. Only the classic layout — core files and wp-content/ in the repository root, no composer.json — is supported; a Bedrock layout (core in vendor/, docroot web/) and a content-only checkout are not. wp-config.php is a shared file, and on a first deploy shared/ is empty, so the target's wp-config.php has to be seeded before the deploy runs or the release keeps the repository's copy — which names the development database. wp db export shells out to mysqldump, so a target needs both wp-cli and a MySQL client.

What the sandbox provides for these recipes

A project whose database is not the framework's default says so in the project layer, which is what makes it rehearsable at all — a Symfony project on PostgreSQL replaces the recipe's lists instead of extending them:

deploy:
  settings:
    sandbox_packages: [postgresql]
    sandbox_extensions: [intl, pgsql, mbstring, xml, curl, zip]
    sandbox_services: [postgresql, redis-server]
    sandbox_tools: [wp-cli]

The four lists replace the recipe's, rather than extending them: a project on PostgreSQL replaces [mariadb], it does not run both.

The pipeline

Deployment is a fixed sequence of framework-neutral tasks, ordered by the engine rather than by a recipe:

Stage Tasks
prepare preflight, lock, release directory, code, shared files, permissions
build dependencies, patches, code generation, frontend assets, static assets — or the artifact
publish maintenance, workers, database backup, configuration, migrations, activation, caches, release record
verify post-publish checks
cleanup prune old releases, release the lock

The maintenance window is opened only when it buys something: a symlink activation is an atomic rename, so nothing serving the site is rewritten, and the window appears only if the same plan also migrates or imports configuration. An in-place activation always opens it, because the docroot itself is rewritten while serving.

For a Magento project a symlink deploy opens the window only when the plan really migrates or imports configuration: the probe below answers whether the schema drifted, and a deploy that changes only code skips the whole downtime block. In place the window opens whatever the probe says, because the docroot itself is rewritten — git reset --hard plus the sync paths — while it serves; the probe answers a question about the schema, not about the rewrite. What bounds the cost is deploy.maintenance_timeout (15m by default) and the fact that the database dump inside the window is opt-in (--no-db-backup).

The window is opened and closed on the release being served (current), not on the release being built. Maintenance mode is read from the docroot a request lands in, so a flag written into the incoming release would protect nothing while setup:upgrade changes the schema the live code depends on, and would switch the site off the moment that release became live. For a symlink that also decides where the window ends: it closes before the swap, because the swap changes which release is being served — closing it afterwards would leave a flag in the release the swap replaced, which is what a rollback would then serve. In place there is one directory throughout, so the window stays open across the rewrite. The guard is the served application (bin/magento in the served directory), not just the directory: a first deploy has no served release at all, and an in-place target's docroot is a git checkout that may never have been deployed to — neither has anything to protect, so both steps are a no-op there.

A framework contributes a recipe that fills the tasks it supports; anything it leaves empty is reported as skipped, not as a failure. A project customises the pipeline by anchoring hooks on a task id, a stage alias or another hook:

deploy:
  hooks:
    - { name: varnish-purge, on: "publish:activate", position: after, order: 10, run: "varnishadm ban req.url ~ /" }

govard deploy plan prints the resolved tree with each step's implementation, and the source of every hook, so a hook's placement can be reviewed without connecting to anything.

Every enhanced behaviour is behind a flag and the defaults are the optimised ones: --no-verify, --no-db-backup and --lock=false turn work off, --force re-deploys a revision the target already runs, and --build, --artifact-dir and --publish change how the release is produced and published.

deploy.settings is validated against the recipe before anything runs: a key the framework does not know, or a value with the wrong shape, is a configuration error (exit 4) that names the key and suggests the near miss. A misspelling used to be a silent no-op — static_content_locale: en_US deployed every locale the recipe defaulted to. String values must be quoted if they look numeric (php_version: "8.2"): the engine reads those settings as strings, so an unquoted 8.2 reads as empty.

Watching a running deploy

The default output is one line per task when it finishes: quiet enough for a CI log, and enough to see which step is running. Two things fill the gap while a step is in flight:

  • a heartbeat every ten seconds — … build:compile still running (42s) — for every step, whether or not its command prints anything. A silent composer install and a stalled transfer look identical without it, and telling them apart matters long before a timeout fires;
  • --verbose, which streams each command's own output live, indented under the task it belongs to. Nothing is batched or reordered: what the command writes is what you see, while it writes it.

--json wins over --verbose: with both, the stream is a no-op and stdout stays exactly the one parseable document (--json already moves the timeline to stderr).

Watching a command never costs the run its own record. What the engine keeps of a command's output is a bounded tail (256 KiB per step, introduced by a marker that says how much was dropped): that copy is what the error message and the release record carry, and a step is free to print far more than that. --verbose is the only thing that shows every byte, and only while it arrives. The bound is not cosmetic — a Magento static content deploy that fails inside a theme loop printed the same exception 27,333 times, and an unbounded copy made the release record 170 MiB, which cannot be written to the target as a command and left status, --resume and rollback with no record of the release at all. Rsync transfers — the in-place sync_paths copy and the artifact upload — add --info=progress2 only when the output is a terminal and --verbose is on: without a terminal rsync cannot redraw its progress line, so every update would become another line in a log instead of a moving one.

Stopping a deploy

Ctrl-C (or a SIGTERM) cancels the run rather than killing the process where it stands. Govard reports the step as the run was interrupted, then applies the same rule a failure does: before the maintenance window the lock is released, because nothing live has changed and a retry must not be refused; once the window is open the lock, the release directory and its record stay, because the release is the only thing that says what the target is half-way through. --resume finishes it.

Interrupting also stops the work, not just govard's own bookkeeping. Every step is a shell chain — cd {{release_path}} && composer install … — so the process govard starts is a shell and the compile, the install or the transfer is its child. A local step runs in its own process group: the group is sent SIGTERM when the run is cancelled, and anything that ignores it is killed as soon as the stopped command returns. Over SSH, killing the local client stops nothing on the other machine, so the step first records the remote shell's pid — sshd gives it a session and process group of its own — and the cancel path signals that group over a second, short-lived connection. The record is removed when the step ends — including a step that replaces its own shell with exec or installs its own EXIT trap — and by the teardown when the step is interrupted, so a normal run leaves nothing behind.

On Windows there is no process group to signal and govard does not create a job object, so a cancelled step kills the shell govard started and a child of that shell may outlive the run. Interrupting is a request to stop, not a guarantee that a step which had already started did — check with govard deploy releases and status before starting another attempt.

Developer and production mode

mage_mode is the one setting that changes what the deploy does rather than how it does it, so it is worth being explicit about in every project:

mage_mode build:assets runs When to use it
not set (the default) yes production behaviour; equivalent to production
production yes a target whose static content is compiled at deploy time
developer no a target Magento generates static files for on demand

The recipe's guard is a plain string comparison, so an empty value behaves exactly like production; only the literal developer skips the step. A developer-mode deploy is therefore minutes faster on a large storefront — no setup:static-content:deploy at all — and the application compiles what a page needs on first request.

deploy:
  settings:
    mage_mode: developer      # staging a team browses; skip static content

Two consequences worth stating plainly:

  • developer is wrong on a production target unless its env.php sets static_content_on_demand_in_production. A production env.php normally leaves it off, so a theme whose static files were never deployed answers 404 for them. A shared staging target whose performance is measured should also use production, because on-demand generation changes the numbers.
  • The split does not apply in developer mode. split_static_deployment only describes how the static content passes are arranged, and in developer mode there are no passes. The recipe's own verification follows suit: the in-place static content version check is guarded on the version file existing, so it passes vacuously when nothing was deployed.

govard deploy plan <remote> shows which mode the guard will compare, before anything connects: the static content step is present either way, and the command the plan prints carries the comparison, for example [ developer != developer ] on a developer-mode target.

The mode is a deploy setting, not a local environment setting: .govard.yml's local environment picks its own Magento mode for development, and the two do not have to agree. It is also descriptive: govard never runs bin/magento deploy:mode:set, so setting developer here tells the deploy what the target runs, it does not make the target run it. Check the target with bin/magento deploy:mode:show and set the setting to match.

Conditional migrate: skipping the downtime block

Most deploys change code, not schema. Running setup:upgrade under a maintenance window for those is downtime the site does not need, so the Magento recipe asks the application first: before the publish block, the engine runs

bin/magento setup:db:status

and reads only its exit code — 0 means every module is up to date, 1 means code and database versions differ, 2 means an upgrade is required. On 0 the six downtime tasks are skipped with the reason db up-to-date (probe exit 0): maintenance:enable, app:workers:pause, app:config:import, db:migrate, app:workers:resume, maintenance:disable. On 1 or 2 they run exactly as before. Any other exit — a dead database, a broken env.php — fails the deploy rather than guessing, because a skipped migration on a drifted database breaks the site while a failed probe merely stops the deploy.

Three steps deliberately stay outside the gate and always run: build:compile (the compiler catches DI errors early, in both modes), app:cache:flush (code changes need a flush even with a current schema), and db:backup (the rollback insurance). The post-deploy verification already runs setup:db:status again, so a probe that lied is caught one stage later.

The semantics around the gate:

  • Resume adopts a recorded migrate verdict. The probe's answer is stored in the release record the moment it resolves, and a resumed run adopts a recorded migrate verdict instead of asking again — the drift it records may have been healed out of band since (an upgrade finished by hand), and a fresh probe would then excuse the teardown the first run already started, stranding the maintenance window open. A recorded skip is never adopted, because drift may equally have appeared since; with no recorded migrate verdict the resume re-probes. Steps an earlier run recorded as ok are still carried over, so a resume never repeats a migration that already succeeded.
  • Artifact mode probes on the target. A build machine has no database, so govard deploy build leaves the probe and the gated block alone; the target runs the probe after receiving the artifact.
  • Rollback does not probe. Returning to a previously-live release needs no schema question.
  • govard deploy plan shows the gate. Gated steps render as conditional (probe at runtime) with a Migration probe: …; exit 0 = skip, 1/2 = migrate line, because the plan phase runs no commands and the probe has no answer yet. The JSON plan carries needs_migration per step for the same reason.

The static content split

split_static_deployment deploys the adminhtml and frontend static content in two passes instead of one, with --area=adminhtml then --area=frontend. The admin pass uses magento_themes_backend (the admin theme by default) and static_content_locales_backend, which defaults to the frontend languages so the two passes agree unless the project says otherwise. The frontend pass uses magento_themes and static_content_locales.

static_deploy_options passes extra flags to every pass — --no-parent for a theme whose parent is deployed on its own, -s standard, or --exclude-theme. A string is passed through verbatim and a list contributes one word per entry, so an entry holding a space is refused while the project is being read (write two entries instead, or use the string form). An entry that needs quoting is quoted — ["en_US", "fr_FR; id"] reaches the application as two arguments, the second one inert — and an ordinary entry renders exactly as it always did.

deploy:
  settings:
    static_deploy_options: --no-parent

It exists for the two cases a single pass handles badly: a theme list that covers only frontend themes (the admin theme would be missed) and a large deployment where one process holding every area runs out of memory. The second pass is chained to the first, so a failed admin pass stops the deploy rather than publishing half of the static content.

Frontend builds (Hyvä)

A Hyvä theme is built by Node inside its own directory, which the project names with settings.frontend_dir:

deploy:
  settings:
    frontend_dir: app/design/frontend/Acme/hyva/web/tailwind
    frontend_command: npm ci && npm run build   # the default

frontend_command runs inside each directory, inside the release, as one shell command: the default chains two commands, so a single-command value such as npx tailwindcss -i input.css -o output.css is written exactly the same way. Leaving frontend_dir empty skips the step, which is what a Luma or stock-theme project wants.

A project with more than one Node-built theme names all of them, and each is built in place:

deploy:
  settings:
    frontend_dir:
      - app/design/frontend/Acme/hyva/web/tailwind
      - app/design/frontend/Acme/other/web/tailwind

The directories are built in order, each in its own subshell, and the first failure stops the deploy rather than letting the next theme's build hide it. Every entry is quoted, so one list entry is exactly one directory: a path that contains a space has to be written as a list entry, because the single-path form is read as a whitespace-separated list.

Node is needed where the build runs, not where the deploy runs: with --artifact-dir the themes are built in the CI build job and build:frontend is skipped on the target, so the deploy job's image stays govard + ssh + rsync.

Deployment case studies works through one Hyvä theme (case 3), the same theme in developer mode (case 4) and two Node-built themes (case 5), with the configuration and the sandbox command for each.

Multiple stores, websites and themes

A multi-store project deploys one release that serves every website, so the deploy set is themes × locales: every store view's theme has to be in magento_themes and every store view's locale in static_content_locales. A storefront whose theme or locale was not built has no static files, and production mode answers 404 for them: Magento only re-publishes a missing static resource on demand when env.php sets static_content_on_demand_in_production, which is a per-request PHP cost rather than a substitute for deploying.

deploy:
  settings:
    # Every theme a store view uses, each with the locales it serves.
    magento_themes:
      Acme/hyva: [en_US, fr_CA]
      Acme/other: [de_DE]
      Magento/luma: [en_US]
    # Optional, and additive: the list applies to every theme above.
    static_content_locales: [en_US]

The locales of a theme map are added to static_content_locales, and the result is deployed for every theme in the map. That is deliberate and it is a property of the application, not a simplification: one setup:static-content:deploy invocation resolves --language once for the whole run, so a single invocation cannot compile theme A for one set of locales and theme B for another. Narrowing per theme would mean one invocation per locale group; the union is what the command can express, and it is the safe direction — an extra locale costs build time, a missing one costs a storefront.

The split still applies: split_static_deployment sends magento_themes_backend (and static_content_locales_backend, which defaults to the frontend locales) to the adminhtml pass and magento_themes to the frontend pass.

What the rest of the pipeline already covers for several websites:

  • app/etc/env.php and pub/media are shared across releases, so per-scope configuration and media survive the swap;
  • app:config:import applies the configuration that lives in config.php and env.php, which is where per-scope settings belong when they are versioned;
  • setup:upgrade, the cache flush and worker control are global, which matches the single database every website shares.

What govard deliberately does not do: it never writes per-store configuration into the database. Base URLs, scope configuration and anything else an operator changes in the admin are the application's data, not the release's, and a deploy that rewrote them would be a deploy that can overwrite a live storefront's settings. deploy.verify.url checks one URL; a multi-store project that wants every storefront checked should anchor a hook on verify and run the checks it wants.

Deployment case studies § Case 6 is this section applied to a real project: a Hyvä storefront with a Luma admin, the split enabled, the union-of-locales rule, and the verify hook that checks every storefront.

Permissions and ownership

writable_dirs lists the paths the application must be able to write, and writable_mode decides how they are made writable:

Mode What it does
chmod (default) chmod -R with writable_permissions (0775)
chown chown -R to owner
chmod+chown both
acl setfacl access and default entries for owner
skip nothing — for a target where an image or a provisioning step already set them

owner is user or user:group, and the chown modes and acl require it: an unknown owner produces a release the web server cannot read, which is worse than refusing. A mode outside the table is a configuration error (exit 4), refused while the settings are validated rather than as a step failure halfway through a deploy.

acl is the mode that also covers the files the application creates later: the default ACL is inherited, so var/, pub/static/ and generated/ stay writable after the deploy without a chown -R over the release. It needs setfacl on the target, and deploy check refuses the deploy before the release directory exists when it is missing.

Caches, opcache and the symlink swap

The served application's cache is flushed as part of the pipeline — the docroot for an in-place target, the release that just became current for a symlink — so the new release never serves a cache built by the old code. What a cache flush does not touch is PHP's own state. After a swap the pool keeps the release it resolved the served path to, and the scripts it compiled from it: measured on a sandbox target (PHP 8.3, opcache on, 2026-09-24) the previous release was still answering 153 seconds after the swap, through killing every php-fpm: pool www worker, and a file that existed only in the new release answered "No input file specified" at the same URL until the pool was reset. deploy:verify is an HTTP check, so in that state a deploy looks successful and still serves the previous release's code.

settings.runtime_reload_command is the supported way to clear that state. It runs as the last part of the app:cache:flush step — inside the window in place, after the swap for a symlink:

deploy:
  settings:
    runtime_reload_command: cachetool opcache:reset && cachetool stat:clear

govard deploy check warns about the gap rather than assuming it: when the target serves a symlink, its PHP loads opcache and the project configures no runtime_reload_command, the preflight says which release deploy:verify may actually be checking. It is a warning, not a failure — a pool reloaded outside govard (a post-deploy hook, an orchestrator) is a valid configuration.

Resetting opcache and the realpath cache is preferred to reloading PHP-FPM: a reload can drop requests that are already in flight, which is why the reference deploy tool's own PHP-FPM recipe warns against it and points at this cache reset instead. The setting is a raw shell command, so anything equivalent works where opcache is not reachable from the deploying user — a PHP-FPM reload, a container restart hook, or the same command through a different tool.

Private Composer repositories

A release built on the target installs its dependencies there, so it needs credentials for any repository that is not packagist. The three routes and their precedence are in Where Composer credentials come from: COMPOSER_AUTH from the deploying environment is forwarded to the dependency step on standard input — not in the command, so it stays out of the target's process list and out of the deploy log — and it overrides every file on the target. shared/auth.json is read only when the project lists auth.json in shared_files, which links it into the release. Govard stores no credentials of its own.

govard deploy check reports which source is in play, and warns when the project declares a private repository and none is available. It warns rather than refuses: the declaration it can read is a URL, and a URL cannot tell it whether a repository needs credentials. Set COMPOSER_AUTH in the CI build job as well as in the deploy job when the artifact is built there.

govard deploy prints the same line when the target builds the release. It is the one prepare preflight, run once by the check and once by the deploy, and a warning that only the check had read would never reach the run that has to act on it.

Build modes

--build=auto (the default) resolves by presence, never by sniffing the environment: an artifact directory means the build already happened, otherwise the target builds.

Mode Where the build runs Use it when
server on the target a hotfix from a laptop, or a project with no CI
artifact on the machine running govard deploy build CI, so the deploy job needs no toolchain

--build says where the build runs; --runner says what runs it. The default host assembles the artifact on that machine's shell, which is what keeps the build job free of a container runtime. --runner container runs the same tasks through the project's own app container with docker exec instead — the way to build a project that cannot fetch its own dependencies from the host, which is what a private-VCS package whose SSH material only the container has looks like. That flag needs a running project container (a stopped or missing one exits 3 with a hint, before the output directory is touched) and an output directory inside the project root (anywhere else is a configuration error, exit 4), and the artifact it produces records the container's PHP, so the deploy job's PHP parity check only clears when the container runs the target's PHP series.

The container carries only its own toolchain, so the build mixes runners when it has to. When the recipe's frontend step will run (deploy.settings.frontend_dir names a directory), the build checks the container for node and npm before the first task. A container that has both keeps the frontend step. One that lacks either hands the step to the host, which then needs node and npm itself (checked before the first task); the PHP and Composer steps still run in the container. Every step prints its runner on the timeline (runner: container <name>, or runner: host (node not in container)), and the artifact manifest records frontend_runner (container or host) next to the container's php_version. Only when neither side has Node does the build refuse, with exit 3, naming what each side lacks and the way out: install Node in the container or on the host (--runner host does not help, it needs Node on the host too), or, for a step that uses the placeholder below, an empty frontend_dir. The previous artifact survives that refusal. The check keys on the recipe's {{settings.frontend_dir_args}} placeholder, so a frontend step that does not use it and needs no Node (a recipe override, a hook-shaped command) is still checked for Node, and an empty frontend_dir does not skip that check; install Node. Ctrl-C and --command-timeout stop the step inside the container as well: a second docker exec signals its process group, and the error says whether that teardown was attempted and whether it completed.

What an artifact can and cannot carry

The build job runs the same recipe the server build runs, minus the steps that ask the application about itself. Static content deployment is the one that matters: setup:static-content:deploy reads the store, website and locale configuration out of the database, so a machine that has no application, no app/etc/env.php and no database cannot run it — naming the themes and locales explicitly does not change that, because the store it asks about is still in the database.

A recipe marks those steps, and artifact mode leaves them in the deploy: the target runs them after deploy:artifact has unpacked the artifact, which is the same place a server build runs them. So the split is:

Runs on the build machine Runs on the target
Composer install, patches, DI compile, frontend (node) build static content, setup:upgrade, configuration import, cache flush, verification

The deploy job still needs no toolchain of its own — it needs govard, ssh and rsync; the target runs the application steps over SSH, as it always did.

An artifact also never carries the paths the recipe declares shared (shared_files, shared_dirs): those belong to the target, and govard deploy build prints every one it drops. Without that, an artifact built on a machine that happens to hold its own app/etc/env.php would replace the target's — a regular file replacing the symlink deploy:shared made — and the release would fail with the application's own words: Connection "default" is not defined.

The two-job CI shape

build:
  stage: build
  script:
    - govard deploy build production --output artifacts --revision $CI_COMMIT_SHA
  artifacts:
    paths: [artifacts/]

deploy:
  stage: deploy
  script:
    - govard deploy production --artifact-dir artifacts --revision $CI_COMMIT_SHA --yes

The deploy job's image needs govard, SSH and rsync and nothing else: no PHP, no Composer, no Node, no container runtime. The five build tasks are skipped on the target in this mode — the artifact carries what they generate — so nothing on the server runs composer install, setup:di:compile or setup:static-content:deploy. The artifact is uploaded into the release and its manifest is checked against the files themselves, the revision being deployed and the PHP the target runs — a CI image that does not match the server, or an artifact whose bytes changed after the build, is refused before anything is published. govard deploy plan --artifact-dir artifacts shows which branch of the build stage is in effect.

An output directory that is not empty is refused, so a file left over from an earlier build cannot ship. Pass --force to replace its contents.

For the full pipeline around these commands — integrity, lint, per-remote single-job ships sharing one workspace, manual rollbacks — see CI pipelines.

Publishing

--publish=auto reads the target instead of guessing:

  • the current path is missing or a symlink → symlink: releases live in releases/<n> and the swap is an atomic mv -T, so no visitor ever sees a half-published tree;
  • the current path is a real directory → in_place: the docroot's objects are fetched during prepare, outside any maintenance window, then the docroot is reset to the exact revision, the configured paths are copied in, and the static content version file is written last.

deploy check reports which one the layout implies and why.

In-place publishing needs sync_paths

An in-place activation resets the docroot to the exact revision, copies the configured sync_paths from the built release into it (with --delete), and writes pub/static/deployed_version.txt last. git reset --hard leaves the previous deployment's gitignored directories where they are, so a path that is not copied is a path the site keeps from the old release — new code over the old vendor/, generated/ and pub/static/, reported as a successful deploy.

The Magento recipe ships the list the strategy needs, and it is the list to keep:

deploy:
  settings:
    sync_paths: [vendor, generated, pub/static/adminhtml, pub/static/frontend]

Three rules the engine applies to whatever the project configures:

  • a path the release did not build is skipped, not fatal — generated/ only exists after setup:di:compile and pub/static/adminhtml only when the admin area was deployed; the activation prints the skip;
  • a path the release links from shared/ is not copied — that path belongs to the shared tree, and the docroot already has its own arrangement for it (a link ensureInPlaceShared maintains, or the directory the docroot owns), so copying the release's copy in would replace live state with a release's placeholder. The step names every path it skipped. This is why pub/static is named by its two built children instead of as a whole: pub/static/_cache is shared;
  • a shared path inside a synced entry is excluded from the copy rather than deleted, so a project that lists pub/static still keeps the docroot's _cache.

sync_paths: [] is allowed and means "copy nothing"; the preflight warns, because that has to be a deliberate decision rather than an omission.

Verification, backup and rollback

deploy:verify runs after publish and is on by default: the live revision (the resolved current symlink, or the docroot's HEAD for an in-place target), the shared state the recipe requires, the recipe's own checks, and an HTTP check when deploy.verify.url is set. The recipe's checks are the ones the engine cannot supply — for Magento that is bin/magento setup:db:status, which needs a working app/etc/env.php and a reachable database — and they run in the served path, so an in-place deploy tests the docroot the site is actually serving rather than the release it was copied from. For an in-place target the engine also dry-runs the activation's own copy for every sync_paths entry (rsync -a --delete --checksum --itemize-changes), so a hook or a process that rewrote a file in the docroot after the copy fails the deploy by name. It reads both trees, which is the cost of comparing content rather than a marker file.

The shared state is checked in two places, and the difference is deliberate. A shared file is checked where the site reads it — current for a symlink, the docroot in place — because a file linked into the release and missing from the served tree is a site with no configuration. A shared directory is checked in the release, which is where deploy:shared puts the link, because an in-place docroot legitimately keeps a copy of its own. Both requirements are conditional on the shared entry existing on the target, since the first deploy of a project has none yet. A release that reads its own copy of a shared directory is the silent failure this check exists for: the build ran against a media tree the site never serves, and every other check passes.

The comparison is one-directional: a release file the docroot has changed or lost is a failure, while a path the docroot has and the release does not is reported instead. The running application creates those — a generated class, a compiled template, a log — and the next activation's --delete removes them, so failing on one would fail a release that is healthy and already serving.

The HTTP check stops at the first response and requires 2xx. A redirect is not a pass: an install page or a store-code bounce that answers 200 after a hop used to satisfy the check while the site was not serving the release, so the failure now names the status, the Location and the setting:

ERROR  step deploy:verify failed: verify http: https://shop.example/ answered
       HTTP 302 to /setup/: the verify URL must name the page that serves the
       site, or set deploy.verify.follow_redirects: true to follow same-host
       redirects

deploy.verify.follow_redirects: true follows redirects that stay on the verify URL's host (http → https is a real layout) and then rejects a landing path the recipe declares never-healthy — Magento's /setup/ — which still fails while following. deploy:check probes the URL with the same policy before the deploy touches anything, and refuses a redirecting URL outright:

ERROR  refusing to deploy to production: the verify URL redirects: https://shop.example/
       answered HTTP 302 to /en/: the verify URL must name the page that serves the site, or
       set deploy.verify.follow_redirects: true to follow same-host redirects

The refusal is deliberate and narrow. A redirect with following off answers the same way on every request, so it is a configuration fault, and the preflight is the last place where fixing it costs nothing — verification runs after activation, where a failure leaves a live site, a failed deploy and a held lock. A timeout, a connection refusal or a 5xx stays a warning: that may be the outage the deploy is about to repair, and blocking a deploy that would fix the site is worse than letting it try.

Behaviour change. A project whose verify.url answers 3xx and which never set follow_redirects used to pass verification and now fails it — at deploy:check, before anything is published. Set deploy.verify.follow_redirects: true, or point verify.url at the page that actually serves the site.

Without a configured URL verification is SSH-only: it proves the right files are in place, not that the application serves. A deploy that runs db:migrate with no verify URL says so before its first step, rather than leaving the operator to assume the opposite.

--db-backup dumps the database into shared/backups/deploy/<n>/ immediately before the first database-mutating task and records the path in the release. The directory is 0700 and the file 0600 — a dump holds customer data, and the deploy account's umask on a shared host would otherwise leave both readable by every local user. deploy:cleanup prunes those dumps on the same keep_releases window as the releases they belong to, so backups cannot accumulate forever on a production box.

The dump reads the connection from app/etc/env.php (Magento) or wp-config.php (WordPress without wp-cli) and gives it to the dump client in a private temporary option file, so the password never appears on a command line or in a log. With --db-backup, deploy check and the run's own deploy:check step refuse a target that has none of wp-cli (WordPress only), mariadb-dump or mysqldump, as a configuration error (exit 4), before anything on the target is changed.

Releases made by earlier versions used Magento's setup:backup, which wrote into the shared var/backups. Dumps left there are not deleted: they may be the operator's own. An upgrade is the moment to look:

ssh <target> 'ls -la <deploy_path>/shared/var/backups'
govard deploy releases staging                  # what is on the target
govard deploy status                            # what every environment serves
govard deploy rollback staging                  # put the previous release back
govard deploy rollback staging --to 12          # ... or a named one
govard deploy rollback staging --with-db --yes  # ... and its database dump

Rollback never rebuilds: a symlink layout is re-pointed, and an in-place layout re-runs the publish tail from the release directory already on the server.

--with-db restores the dump recorded by the release that ran after the one being restored, not the target's own. A dump is taken before its release migrates, so release 6's dump is the database exactly as it was before the migrations this rollback undoes; the target's own dump is the state before its migrations, one schema older than the code being put back, and every write since would be lost. When that release recorded no dump the command refuses and names the release to re-deploy with --db-backup — it never substitutes another release's dump.

The restore is bracketed by the maintenance window on its own, and it runs before the cache flush and the verification — for either publish strategy. The order matters because each of the three consumes the previous one's output: the caches the application rebuilds are built from the database, and the framework checks deploy:verify runs (setup:db:status, migrate:status) query it. A flush first would repopulate the caches from the data the restore is about to replace, and an in-place rollback's tail holds its own verification back for the same reason.

A rollback takes the deploy lock for the whole operation: it is refused while another run holds the lock, and it releases the lock when it ends. A symlink rollback also flushes the caches through the recipe's app:cache:flush step (including runtime_reload_command), because the swap changes which code runs while the state the target serves — compiled configuration, caches, Redis keys — still belongs to the release that was live a moment ago. An in-place rollback does that as part of its publish tail.

If that tail fails, the maintenance window stays open and the error says so — a site left in maintenance looks like an outage to whoever finds it. The lock is already released by then, and the release being rolled back to is untouched: the tail writes to its record as it runs, so a failure would otherwise store failed against a release that is perfectly good and take it out of every future rollback --to. Re-run the same rollback once the cause is fixed.

deploy.lock_stale_after (default 2h) is how old a lock must be for govard deploy unlock to release it without --force; the refusal a held lock produces names the holder, its revision and how long it has been held. Only a lock directory that exists after the failed mkdir counts as held: any other failure to acquire it (permissions, a read-only filesystem, a full disk, a file at the lock path) reports mkdir's own error as acquire deploy lock: ..., so it never sends you to deploy unlock for a lock that is not there.

deploy.maintenance_timeout (default 15m) bounds any single step that runs with the site in maintenance mode, which is far below deploy.command_timeout on purpose: a slow step there is holding the site down. Raise it for a project whose database dump legitimately takes longer.

deploy.command_timeout (default 30m) bounds every other step, and it is the one to raise first when a deploy times out: a cold composer install of a large project — several hundred packages, private repositories cloned over the network — can take longer than that on a fresh target, and the failure reads command timed out on that step. A timed-out step is not a special case of anything: the lock goes back if the run had not reached the maintenance window, the record says what stopped, and the retry resumes from a warm Composer cache on the target.

deploy:
  command_timeout: 90m

A failed deploy keeps its release directory and its record. Where it failed decides the lock: a failure in prepare or build releases it, because nothing live has changed, so the fault can simply be fixed and the deploy retried — while a failure from publish onwards keeps it, because the target may be half-changed, and the way forward is govard deploy <remote> --resume, which continues the newest unfinished release instead of starting a new one. --from <task> starts at a named task or hook, and govard deploy unlock releases a lock a failed run left behind.

One failure looks like another in the exit code alone. Exit 255 from the SSH transport is what govard sees when the connection drops mid-step, and a step is free to exit 255 for its own reasons — PHP does it for every fatal error, so a setup:di:compile that runs out of memory ends exactly that way. The number cannot tell the two apart, so the wrapper govard runs around every remote step reports the step's own status when it exits 255, and only a 255 without that report is a dropped connection. That distinction decides what happens next:

  • A dropped connection means nothing signalled the step's remote process group (with BatchMode and no pty, sshd sends no SIGHUP), so the step may still be running. govard spends up to 20 seconds trying to stop it, and the hint names the ambiguity instead of claiming a clean failure. Check the target first — govard deploy status <remote> — before --resume (a publish-stage failure kept the lock) or a plain retry (a build-stage failure released it), because resuming into an unknown half-run is how a migration that died mid-way becomes a broken one.
  • A step that exited 255 itself is an ordinary failed step: the exit code is in the message, nothing is torn down, and the hint is the same one every other failure gets.

Two rules keep those entry points honest. --from is only accepted together with --resume: a run that starts after deploy:release has no release number, and {{release_path}} would then be the directory that holds every release rather than one of them. And --resume refuses a release that is still running under a lock younger than deploy.lock_stale_after (that lock belongs to a deploy that may be alive — release it with govard deploy unlock, or with --force when it is younger than that), and refuses a release strictly older than the live one, which is what a CI retry that always passes --resume would otherwise activate over a serving release. The release a run failed on after activation is not older than the live one — it is the live one — so resuming it is the recovery, not the hazard. A run started with --from/--resume takes the deploy lock itself, so the step it skipped does not leave the target unprotected.

A resume also keeps the release's build mode. The mode (server or artifact) is stamped on the record when the release starts, and --resume without --build continues in that mode: an interrupted artifact release never runs the build tasks on the target, and needs --artifact-dir again only if deploy:artifact had not yet succeeded. An explicit --build that names the other mode is refused as a usage error (exit 2) instead of mixing modes in one release.

A resume can be repeated as often as it takes, and it continues the same release every time. A step an earlier attempt already succeeded at is not run again, and the record keeps the ok that attempt stored, so a resumed run shows it as already done in an earlier run and nothing is built twice. The release directory is only refused when it does not carry govard's own record for that release: a directory another tool created is protected, while govard's own half-finished release is continued rather than blocked.

Machine-readable output

--json writes exactly one JSON document to stdout and everything a human would read to stderr, so a CI job can pipe stdout into a parser and keep stderr in the log:

{"schema_version":1,"remote":"production","branch":"main","revision":"0123abc…",
 "release":"42","build":{"mode":"server"},"publish":{"strategy":"symlink"},
 "verify":"ok","result":"ok","duration_ms":94300,
 "tasks":[{"id":"deploy:check","stage":"prepare","status":"ok","duration_ms":19}]}

A failing deploy produces the same document with "result":"failed" and an "error" that names the task, host and command; the process exits 1. The release record carries ci.pipeline/ci.job when the run is a CI run, which is how "which pipeline deployed this" is answered afterwards from govard deploy status.

The sandbox

govard sandbox gives a project a real deployment target on your machine — a container that plays the remote — so a deploy can be rehearsed before it touches a server. Nothing in the pipeline knows the difference, which is what makes it a rehearsal rather than a simulation.

govard sandbox up                      # create it (php profile by default)
govard sandbox up --profile basic      # sshd, rsync, git only
govard sandbox up --profile full --php 8.4   # database, cache, web server, PHP 8.4
govard sandbox status
govard sandbox reset --layout deployer # seed a target the other tool owns
govard sandbox ssh                     # or: govard sandbox ssh -- <command>
govard sandbox down [--purge] [--volumes]

sandbox is a top-level command. Its containers are named govard-<slug>-sandbox-… and its images govard-sandbox:<slug>-<profile>-<hash>.

up publishes SSH on a free loopback port, generates a dedicated key under .govard/sandbox/ (gitignored), and mounts a mirror of your local repository read-only. The mirror is refreshed before every deploy, so a commit you have never pushed is deployable. Whenever the sandbox container is running, sandbox resolves automatically as a remote for every command that takes one — deploy, db, remote exec, sync — so govard deploy --remote sandbox --yes, govard db dump -e sandbox, govard remote exec sandbox -- <command> and govard sync -e sandbox all work with no identity ever written to configuration. govard remote list shows that synthetic row (sandbox (implicit) | running | files,media,db | keyfile | …) next to the configured remotes: the name carries the (implicit) marker, the host column carries the liveness (running, dormant — …, absent — …), and the capabilities column carries what a remotes.sandbox block configures. Deploy to it with the flag form (the positional form works too):

govard deploy --remote sandbox --yes

--docroot shapes the target so the publish strategy resolves the way you want to exercise it: absent or symlink (the default) selects the atomic swap, real selects in-place publishing. One sharp edge of the default symlink shape: on a fresh sandbox govard remote exec sandbox -- <command> fails, because the command starts in the target's current path and that symlink dangles until the first deploy populates it. Run the first deploy, or create the sandbox with --docroot real, to avoid it. Shaping happens when the target is created and when you name a shape, because up is also how a stopped sandbox is started and how the mirror is refreshed before the next revision is deployed — neither may cost the application currently being served. reset shapes unconditionally: wiping the deploy directories and laying them out again is what it is for. down stops and removes the container — the implicit sandbox remote exists only while it does, so there is nothing left to clean out of any configuration file — and keeps every data volume so a rehearsal resumes tomorrow; down --volumes deletes the derived volumes too. --purge also removes the images of every profile built for the project, the key and the mirror. The state directory .govard/sandbox/ ignores itself (it holds a .gitignore of *), so git add -A never stages the private key.

A sandbox is a derived project, not a generic container: up renders the origin project's own blueprint — same PHP series, same services — into a dedicated container (govard-<slug>-sandbox-…) built from its own image (govard-sandbox:<slug>-<profile>-<hash>), so the rehearsal target matches the project by construction instead of by hand-passed --php (which remains as an override for exceptional cases). The derived project never appears in the project list; its state lives under the origin's .govard/sandbox/, recording what it was seeded from.

up also seeds the application once, from the running origin environment: a logical database dump (the origin keeps running — nothing is stopped or mutated), the media tree, and the env file rewritten for the sandbox (base_url becomes the sandbox web URL; container-local hosts stay as they are). The dump is followed by the framework-owned database rewrite where one is registered (Magento's base_url across every scope), so the seeded site answers at the sandbox URL instead of redirecting to the origin. The dump and the media tree are streamed from the origin container into the sandbox container through the govard process, one line of SQL (and one tar block) at a time, so seeding a multi-gigabyte database costs buffers rather than a copy of the whole thing. The database password travels in the runtime's environment — passed through by name, never as an argument and never as a NAME=value argv entry — so a local ps cannot read it; inside the container it is still visible to that container's own process list while the client runs. A dump that fails midway leaves the sandbox database partial, which the seed reports rather than hiding: re-run govard sandbox up. The seed runs only on a fresh container, and its database part only into an empty database: the full profile keeps the database in a named volume that down leaves alone, so a new container on a populated volume skips the import (it says so, once) but still gets the media and the env file, which live in the container. --reseed refreshes both from the origin; down --purge discards the volume. The origin environment must be running, or up refuses and says so — a silent empty sandbox helps nobody. --no-seed starts deliberately empty, and --reseed with --no-seed is refused.

The fast loop

A rehearsal repeats the same steps, so the sandbox reuses what it can. All of it applies to the sandbox remote only; a real remote builds and deploys exactly as before.

  • A fixture instead of a fresh install. A first install takes minutes. scripts/sandbox-fixture.sh <dir> creates an installed project once (init, bootstrap --fresh, then a commit of the files the build reads); afterwards a stopped fixture comes back with env start, or with snapshot restore.
  • The database stays. The full profile mounts the named volume <sandbox container name>-db over the database data directory. down keeps it, down --purge removes it, up seeds only an empty database, and --reseed refreshes it. A volume written by one database series is never opened by another: --db (or the stack) naming a different series is refused, before anything is built, with a --purge hint. --recreate rebuilds the container and keeps the volume, so it no longer refreshes the data by itself.
  • The artifact is cached. govard deploy build sandbox resolves the running sandbox without a configured remote and caches the finished artifact under .govard/sandbox/build-cache, keyed by the commit, the sha256 of composer.lock at that commit, the PHP series, the build mode and runner, the effective deploy settings, hooks and recipe commands, the builder's PHP, Composer and Node versions, and the govard binary. A hit hard-links the cached tree into --output and prints artifact cache hit; --no-cache rebuilds. The three most recently used entries are kept and down --purge removes them. Do not edit a built output in place: hard links share content with the cache.
  • Static content is reused. When the artifact (revision, PHP, lock file, every file's digest) and the rendered static-content command are the same as at the last run, the sandbox hard-links the earlier release's static content instead of generating it again and the timeline says why. A changed artifact, a changed command (themes, locales, jobs) or missing earlier content runs the task. Changes the fingerprint cannot see, such as a theme enabled in the database, need settings.sandbox_reuse_assets: false under deploy: to keep the task running.
  • Credentials for a server build. --build server on the sandbox gets the project's auth.json, or else ~/.composer/auth.json, for the run (unless COMPOSER_AUTH is set); they travel on the command's standard input, never in argv or on disk. An artifact deploy installs nothing on the target and reads no credentials.

On a warm machine (images present) the loop measured about 6 s for the fixture, 27 to 33 s for sandbox up with a seed, 69 to 79 s for the artifact build and 50 s for the deploy before these changes; the build and the seed are the parts that now drop to seconds on a repeat.

A sandbox you already have is described by what it is, not by the flags of the command that reached it: up reports the profile and the PHP series the container was built for, and the image it actually came from, so a later up without --php does not erase the series and does not describe a full sandbox as the default profile. A stopped (dormant) sandbox keeps that description: status still reports the profile and the PHP series, read back from the container's own labels, and the mirror and key paths, which come from the project root. The deploy and current paths and the published ports stay empty until the container runs again, because a dormant remote resolves to nothing by contract. Naming a profile or series that disagrees is refused with the flag that actually changes it (--recreate), rather than silently relabelling a container whose image still ships the old one. up also waits for a real login before it reports the sandbox ready — a published port that accepts a connection is not a target a deploy can start against. The synthetic sandbox remote is resolved live from the container's state on every use (host, port, paths, branch, mirror, verify URL, and the settings the profile implies), so there is nothing to hand-edit and nothing that can drift; put what you want to keep in the project's own configuration instead.

The php and full profiles also ship a web tier: nginx serving the served path plus the project's stack.web_root (/pub for Magento), and PHP-FPM running as the deploy user, so the application can write the directories deploy:writable hands over. up publishes that port on loopback too and points the sandbox remote's deploy.verify.url at it, which means a sandbox deploy rehearses the whole pipeline, HTTP check included — the one step a target without a web server could never exercise.

The web tier buffers large FastCGI response headers (fastcgi_buffer_size 128k, the development stack's value); a sandbox container created before that setting keeps the old nginx configuration until it is recreated (sandbox down --purge, then up).

That check is real: a target that does not answer yet fails the last step with the HTTP status it returned (verify http: http://127.0.0.1:PORT/ returned HTTP 403), which is the check doing its job rather than a defect. A seeded sandbox already provides the application (database, media, rewritten env file), so the check passes with no hand provisioning; --no-verify turns it off for a rehearsal that stops at the files, and --no-seed is the rehearsal that provides its own application instead.

The full profile starts a database and a cache, and the Magento recipe names both, so a target's env.php can point at 127.0.0.1 for MariaDB and Redis/Valkey — the shape a server has — instead of being hand-edited to use files. The basic profile ships neither and advertises no verify URL.

When the project enables the RabbitMQ queue service, its management UI is reachable at project.test:15672 through the shared proxy. That port is published on 127.0.0.1 only, so the UI is loopback-only by construction — never reachable from the LAN. The default guest/guest login travels over plain HTTP, so treat it as a local-development convenience and never expose it beyond the machine.

--php picks the PHP series the image provides, for example --php 8.4; without it a new sandbox is built for the project's stack.php_version when it names a series, and for the base distribution's own version otherwise, while an existing sandbox keeps the series it ships (only a named --php can disagree with it, and that is refused with --recreate). The series comes from the sury repository and the image's php binary, its extensions, php_bin and the php_version the remote declares all follow it — so a project whose composer.lock requires a newer PHP than the base image carries can be rehearsed against the PHP its target actually runs, instead of failing in the middle of a dependency install. The series is part of the image tag, so asking for a different one builds a different image rather than reusing the old one.

--db picks the database the full profile provides: --db mariadb:10.6, or --db default for the base distribution's own server. Without it a new sandbox installs the MariaDB series of the project's stack.db_version from the official MariaDB repository (pinned above Debian's own, and the build fails if the installed server is not that series), so a Magento 2.4.6 rehearsal runs on the MariaDB 10.6 it supports instead of Debian's 10.11. A stack that names no database keeps the distribution's server, and sandbox up prints the installed version either way. A MySQL stack is refused with a message naming the mismatch, because the sandbox only provides MariaDB; pass --db mariadb:<series> to choose one anyway. The series is part of the image tag, and an existing sandbox keeps the database it ships (a disagreeing --db is refused with --recreate). The basic and php profiles have no database, so sandbox up skips the database seed with one note instead of failing; use --profile full to seed.

A rehearsal is only as complete as the credentials the deployment has, and the three routes are not interchangeable — see Where Composer credentials come from above. What matters for a sandbox is that it is a fresh target: a git-type package needs a key and a known_hosts entry inside the container, and a credential that lives only in your shell profile is exactly the kind that can override the project's own working auth.json and fail the build where a plain govard deploy would have succeeded. Put the credentials where the target can use them (docker exec, or a mounted file) and re-run govard deploy --remote sandbox --yes; the failing step resumes from a clean release directory and the Composer cache is kept.

The same is true of the application the target is supposed to be serving — with one exception: a seeded sandbox (the default) already brings its own. Only a --no-seed sandbox starts with no installed application, and there a pipeline that reaches build:assets or db:migrate stops on a prerequisite rather than on a defect: setup:static-content:deploy needs the store configuration, and setup:upgrade needs a database and a supported search engine. shared/app/etc/env.php on the target is what the release links for all of it, so a rehearsal in an unseeded sandbox means writing that file (pointing at a database the target can reach, with a cache backend and session handler it can reach) and having the data behind it — a govard bootstrap -e <env> clone is the usual source. Those prerequisites are the target's, not the engine's: a server that has never run the application cannot publish a release to it, and an unseeded sandbox refuses to pretend otherwise.

Configuring the sandbox remote

The sandbox's identity is never taken from configuration. Host, port, user, path, auth and the database credentials are read from the container sandbox up created, every time. That is the whole point of a rehearsal, and it is why the identity is not merely defaulted but closed: an identity that silently came from a config file would point the pipeline at another machine and report success.

A remotes.sandbox block may still state the rehearsal's shape. It is layered over the container-derived remote — an explicit allowlist, not a merge of two peers:

Field Source
capabilities block (files, media, db)
protected block
the copied deploy.* fields (below) block
deploy.settings.owner, .writable_mode, .php_bin, .php_version container
host, port, user, path, url, auth.*, paths.*, db_*, local, sandbox container

The four pinned settings describe what the image shipped — which deployer account owns the files, how they are written, and which PHP binary and series it carries. A deploy refuses to run when the target's PHP does not match the declared series, so a block-supplied php_version is exactly how a rehearsal would fail for the wrong reason.

What the block may set is the set a per-remote override actually copies, not all of deploy.*: keep_releases, command_timeout, artifact_dir, repository, branch, publish, deploy_path, db_backup, verify.url, verify.timeout, hooks, and settings key by key — writable_permissions and composer_bin among them, since neither is pinned. deploy_path is the one that moves the rehearsal inside the container, so it is worth stating plainly: it repoints where the release is published within the sandbox, and cannot reach outside it, because the host it names is the container's.

Three fields are project-level only and a sandbox block is ignored for them — lock_stale_after, maintenance_timeout and verify.follow_redirects. They are inert for every remote, not just this one, so an operator who sets one in a remotes.sandbox block gets the project's value and no warning; keep them in the project-level deploy: block where they are actually read.

Write it by hand:

# .govard.yml or .govard.local.yml
remotes:
  sandbox:
    capabilities:
      db: false          # rehearse with no database writes
    protected: true      # require --yes even for a local rehearsal
    deploy:
      keep_releases: 3
      settings:
        writable_permissions: "0775"

or with the command, which applies the same allowlist — identity flags are dropped and reported, never stored:

govard remote add sandbox --capabilities db --protected

govard remote list reports a configured sandbox as layered over the synthetic one rather than warning you to delete it. (Earlier releases of this page told you to delete the block: remote add sandbox was refused and a hand-written block was treated as dead weight. That reversed deliberately — an operator has to be able to state the rehearsal target's shape, and the half-state where a block was parsed but ignored by every identity consumer was worse than either alternative.)

The shared SSH gateway

govard svc up also starts a small bastion, govard-proxy-sshd, on 127.0.0.1:2222. Once a sandbox is up and at least one client key is allowed, it is reachable at a stable address instead of the ephemeral port sandbox status prints:

govard gateway allow-key "$(cat ~/.ssh/id_ed25519.pub)"
ssh -p 2222 <project-name>@127.0.0.1
sftp -P 2222 <project-name>@127.0.0.1
  • govard gateway status reports whether the bastion container is running and how many targets/keys it knows about.
  • govard gateway allow-key <public-key-line> / govard gateway revoke-key <fingerprint-or-comment> manage the allowlist; both work without Docker running (the registry is a local file).
  • sandbox up registers the project's username automatically and joins the bastion's network; sandbox down removes the registration. Neither operation fails if the gateway itself is not running -- it is an added convenience, not a new dependency of the deploy pipeline's own direct SSH path (deploy, deploy check, remote * never go through it).
  • A username with no registered target, or an allowlisted key with no matching one, gets a standard SSH refusal. A registered target whose container is stopped gets a specific "is not reachable" message instead of a hang.

One connection per target

Every command a run issues shares a single SSH connection per target (ControlMaster with a socket under ~/.govard/ssh/, reused for 60 seconds after the last command). A Magento pipeline runs around twenty commands, so the handshake and authentication are paid once instead of twenty times — and about half of those commands run inside the maintenance window, where a second saved is a second of downtime avoided. The sockets are local to the machine running the deploy, and they expire on their own; nothing is left on the target.

Capabilities and exit codes

Every deploy command declares what it needs, and a missing requirement is exit 3 with an actionable message before any work happens:

Command Requirement
govard deploy / rollback ssh,rsync
govard deploy check / releases / status / unlock ssh
govard deploy plan / build none
govard sandbox * docker

Exit codes: 0 success, 1 execution failure, 2 usage, 3 missing capability, 4 configuration. The deploy job in CI therefore runs on a host with nothing but govard, SSH and rsync. A failing remote step never leaks its own status (255, 22, 127, ...) as the process exit code: the run exits 1 and the remote status stays in the message (command failed (exit N)).

A remote that is not in .govard.yml is a configuration error (4) for every deploy command, for deploy status and for sync alike: the remedy is an edit to the file, never to the command line. Exit 1 stays for a remote that is configured and whose target could not be read, which is the distinction a script branching on the exit code is reading.

deploy check and deploy status share the configuration and usage codes: a mistyped deploy.settings key, a project that cannot be loaded and a project with no remotes are configuration errors (4), a positional remote that contradicts --remote is a usage error (2), as is a deploy check with no remote named. Exit 1 differs: for check it means the preflight failed or the named target could not be reached, for status it means no configured remote could be reached. deploy status --json still prints the array of per-remote rows (an unreachable one has status unknown and an error) but exits 1 when every remote is unreachable, like table mode, with the reason on stderr; with no remotes configured it exits 4 and prints no rows.

govard deploy build keeps its none row whatever --runner is set to: a static annotation cannot say "only when this flag is passed", so the one flag that needs a container runtime asks for it when the flag is read and fails with the same exit 3 and the same kind of message. --runner host — the default — asks for nothing.