| 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. |
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 impliesThe 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.
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.
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 filedeploy:sharedlinks 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:deploystops atThe default website isn't definedwhen the store tables are missing, and migration cannot run at all; - a supported search engine where the project uses one.
setup:upgraderefuses 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
rsyncon 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.
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".
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) andfrontend_command(defaultnpm 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(developerskips static content deployment) — see Developer and production mode; - workers:
worker_control: truerunscron:remove/queue:consumers:restartaround the deploy and restores them after — see the pipeline table; - opcache:
runtime_reload_commandafter 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) andwritable_permissions— see Permissions and ownership.
Every key either recipe declares, with its default and its shape, is tabulated in Deployment case studies § Reference.
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.
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.
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 stagingIt 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.
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 --purgeThe 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.
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 |
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.
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.
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.
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.phpdb: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"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 targetKeep 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.
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.
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.
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 silentcomposer installand 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.
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.
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 contentTwo consequences worth stating plainly:
developeris wrong on a production target unless itsenv.phpsetsstatic_content_on_demand_in_production. A productionenv.phpnormally leaves it off, so a theme whose static files were never deployed answers404for them. A shared staging target whose performance is measured should also useproduction, because on-demand generation changes the numbers.- The split does not apply in developer mode.
split_static_deploymentonly 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.
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
okare 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 buildleaves 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 planshows the gate. Gated steps render asconditional (probe at runtime)with aMigration probe: …; exit 0 = skip, 1/2 = migrateline, because the plan phase runs no commands and the probe has no answer yet. The JSON plan carriesneeds_migrationper step for the same reason.
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-parentIt 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.
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 defaultfrontend_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/tailwindThe 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.
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.phpandpub/mediaare shared across releases, so per-scope configuration and media survive the swap;app:config:importapplies the configuration that lives inconfig.phpandenv.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.
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.
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:cleargovard 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.
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=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.
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.
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 --yesThe 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.
--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 atomicmv -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.
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 aftersetup:di:compileandpub/static/adminhtmlonly 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 linkensureInPlaceSharedmaintains, 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 whypub/staticis named by its two built children instead of as a whole:pub/static/_cacheis shared; - a shared path inside a synced entry is excluded from the copy rather than
deleted, so a project that lists
pub/staticstill 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.
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 dumpRollback 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: 90mA 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
BatchModeand 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.
--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.
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.
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 withenv start, or withsnapshot restore. - The database stays. The
fullprofile mounts the named volume<sandbox container name>-dbover the database data directory.downkeeps it,down --purgeremoves it,upseeds only an empty database, and--reseedrefreshes 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--purgehint.--recreaterebuilds the container and keeps the volume, so it no longer refreshes the data by itself. - The artifact is cached.
govard deploy build sandboxresolves the running sandbox without a configured remote and caches the finished artifact under.govard/sandbox/build-cache, keyed by the commit, the sha256 ofcomposer.lockat 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--outputand printsartifact cache hit;--no-cacherebuilds. The three most recently used entries are kept anddown --purgeremoves 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: falseunderdeploy:to keep the task running. - Credentials for a server build.
--build serveron the sandbox gets the project'sauth.json, or else~/.composer/auth.json, for the run (unlessCOMPOSER_AUTHis 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.
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 --protectedgovard 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.)
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.1govard gateway statusreports 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 upregisters the project's username automatically and joins the bastion's network;sandbox downremoves 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.
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.
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.