Vortos
Deploy

Deploy

A fail-closed deploy engine — content-hashed plans, swappable targets, phase-gated migrations, and zero-standing-secrets credentials.

Deploy

vortos-deploy is the engine that turns "ship this build to production" into a sequence of verifiable, resumable steps instead of a shell script someone runs by hand and hopes works. It is built around one rule: when in doubt, refuse, don't guess. A deploy that can't verify its preflight checks, can't confirm a cutover went live, or can't prove a rollback is schema-safe stops — it never silently proceeds on a best-effort basis.

Pure planning, then execution

Planning a deploy and executing it are two separate, deliberately decoupled steps. DeployPlanner::plan() is pure — given the same DeployContext, it always produces the same DeployPlan, with no I/O. Only deploy:doctor and the actual target driver touch real infrastructure. This is what makes --dry-run meaningful: it runs the exact same planning code path as a real deploy, it just stops before the target driver is invoked.

How a deploy plan is built

DeploymentDefinition (your config: strategy, target, environment)


DeployContext (definition + CurrentDeployState — what's actually running now)

    ▼ DeployPlanner::plan()
    ├── PhaseGate::assertNoPendingContract()   — refuse if a contract migration is still pending
    ├── DeployStrategyRegistry::get(strategy)  — resolve the configured strategy
    ├── strategy->phases($context)             — strategy decides the phase sequence
    └── PhaseOrderPolicy::assertValid($phases)  — refuse if phase ordering is unsafe


DeployPlan (an ordered list of DeployStep, each tagged with a DeployPhase)

    ▼ PlanHash::fromPlanJson()
content-hashed plan — sha256 over the canonical JSON

Every plan is content-hashed. Two deploys with identical definitions and identical current state produce byte-identical plans with the same hash — this is what lets deploy --resume verify that the plan it's resuming is the plan it already started executing, not a different one that happened to be requested with the same command.

Targets — where a deploy actually runs

DeployTargetInterface is the Ops Kit port every deploy target implements:

interface DeployTargetInterface extends DriverInterface
{
    public function plan(DeployContext $context): DeployPlan;
    public function assertImageAvailable(ImageReference $image): void;
    public function migrate(DeployPlan $plan): void;
    public function release(DeployPlan $plan, EnvironmentName $env): TargetStatus;
    public function rollback(DeployPlan $plan, EnvironmentName $env, ?BuildManifest $targetManifest = null): TargetStatus;
    public function status(EnvironmentName $env): TargetStatus;
}

There is no push(): the CI build job is the only thing that pushes an image. Before mutating anything, a target instead calls assertImageAvailable() — a fail-closed check that the pinned repo@digest really exists — so a missing or mistyped digest fails loudly up front rather than as a broken pull on the target. In the deploy-in-image posture the image was already pulled onto the host, so the digest is resolved daemon-first (docker image inspect, over the least-privilege socket-proxy — no registry tool or credentials needed), falling back to registry-direct resolution (crane/skopeo/buildx imagetools) only when the image isn't local. For the same reason pull() short-circuits when the digest is already present locally rather than re-authenticating against the registry. The repository a target deploys is read from the plan (threaded from the build manifest), never hardcoded.

Two targets ship today:

TargetDriver keyUse case
SSH + Docker Composessh-composeA bare VPS or small fleet — no orchestrator required
Kubernetesk8s (from DeployK8s)A cluster — installed as a separate package

Because both implement the same port, switching targets is a config change, not a rewrite of your deploy pipeline.

Strategies

A strategy decides the phase sequence for a deploy — what happens, in what order, and what each phase needs from the target. Each declares its requirements via Ops Kit capabilities, so a target lacking a needed capability is rejected at plan time, not mid-rollout:

StrategyWhat it does
BlueGreenStrategyDeploy to the idle color, verify, cut traffic over atomically
CanaryStrategyRoute a small percentage of traffic to the new version, analyze, then promote or abort
RollingStrategyReplace instances incrementally across a node set
RecreateStrategyTear down, then stand up — simplest, briefest downtime window

See Strategies for how to pick one and how to write a custom strategy.

The phase gate and expand/contract migrations

The phase gate exists for one specific danger: deploying a contract migration (one that removes something — a column, a table) in the same deploy as the code that depends on the old schema being gone, while the old code might still be running on another instance during a rolling deploy.

$this->phaseGate->assertNoPendingContract($context->currentState);

If a contract migration is still pending from a previous expand/contract cycle, planning refuses outright with ContractInSameDeployException — rather than risk a window where half your fleet runs old code against new schema. See Phase Gate for the full expand/contract lifecycle and how it ties into Release's rollback invariant.

Cutover and the edge router

For strategies that switch traffic atomically (blue-green), CutoverCoordinator is the choreography: cut over, verify the new upstream is actually live, and only then record the new release. If verification fails, it automatically reverts to the previous color and records why — a cutover never leaves you on an unverified upstream silently.

$result = $cutoverCoordinator->cutover($desiredRoute, $imageDigest, $buildId, $planHash, $previousEndpoint);

Every recorded release carries a monotonically increasing generation number, providing compare-and-swap semantics for the edge router state — a stale reconciler can never overwrite a newer release by accident. See Caddy Cutover for the edge-router driver and the reconciler that keeps Caddy's running config converged with the desired route.

The deploy-state store must be durable (DEPLOY_STATE_STORE)

The active color, generation, image digest, contract-soak ledger, pull-agent freshness, and reconcile rate-limit all live in the deploy-state store — one store, selected by DEPLOY_STATE_STORE: redis (default, symmetric with EDGE_STATE_STORE, uses the shared \Redis), file, or mongo. This matters because the deploy runs as a docker run --rm one-shot: a file store lives at %kernel.project_dir%/var/deploy-state inside that container and is destroyed after every run, so the current-release record is lost — blue-green never alternates color and deploy:rollback cannot see the live release. Keep the default (redis) for the deploy-in-image topology; file is only safe on a single-node box with a persistent var/deploy-state. deploy:doctor fails closed (via DeployStateDurabilityCheck) on a file store in the one-shot topology, or on redis selected with no REDIS_* connection configured.

Container registry authentication

At deploy time, the target driver authenticates to the container registry before pushing or pulling images. RegistryAuthStrategyInterface is the Ops Kit port — each strategy handles one credential type and passes secrets via stdin, never in CLI argv:

interface RegistryAuthStrategyInterface extends DriverInterface
{
    public function supports(RegistryCredential $credential): bool;
    public function login(CommandRunnerInterface $runner, RegistryCredential $credential): void;
    public function redactTokens(RegistryCredential $credential): array;
}

Three typed credential classes cover the registries that ship out of the box:

Credential classRegistryAuth mechanism
PatTokenCredentialGHCRUsername + PAT, passed via --password-stdin
BasicAuthCredentialDocker Hub, customUsername + password, passed via --password-stdin
GcpServiceAccountCredentialGCP Artifact RegistryService account JSON, _json_key login via stdin

The ContainerRegistryInterface port is what StepExecutor and SshComposeTarget use — swap the default GhcrRegistry for DockerHubRegistry or GcpArtifactRegistry by rebinding the alias in your DI config. Custom registries are additive: implement the port, tag with #[AsDriver('my-registry')].

Zero-standing-secrets credentials

Deploy never holds a long-lived credential at rest. CredentialProviderInterface::issue() mints a short-lived credential (an SSH certificate signed by an internal CA, an OIDC-exchanged registry token) at the moment it's needed, scoped to one environment, and it expires shortly after:

interface CredentialProviderInterface extends DriverInterface
{
    public function issue(EnvironmentName $env): IssuedCredential;
    public function assertIssuable(EnvironmentName $env): void;
}

assertIssuable() is the important part for safety: it's a non-mutating check that proves a credential could be minted — config present, signer reachable, backing secret available — without actually minting one. This is what deploy:doctor calls. A preflight check should never itself create a standing secret just to verify the path works.

See Credentials for the SSH-CA-OIDC, SSH-key, and pull-agent providers.

Configuring a deployment (config/deploy.php)

The base deployment definition is built from config/deploy.php — a closure that receives the default DeploymentDefinitionBuilder and returns a configured one. This file is now loaded by the package (previously it was documented but never read, so you had to override the service). Per-environment overrides layer on top.

config/deploy.php
use Vortos\Deploy\Definition\DeploymentDefinitionBuilder;

return static fn (DeploymentDefinitionBuilder $b) => $b
    ->host('ssh-compose')
    ->registry('ghcr')
    ->strategy('blue-green')
    ->pruneImages(keep: 2)                 // reclaim superseded release images after cutover
    ->backupToolchainExternal(true)        // the DB client lives on the backup role, not this image
    ->forEnvironment('production', static fn (DeploymentDefinitionBuilder $b) => $b->autoRollback(true));

A few more builder options worth knowing:

  • ->pruneImages(enabled = true, keep = 2, builderCacheMaxAge = '168h') — after a successful cutover the target reclaims superseded release images and old build cache, keeping the active release and the previous-for-rollback (by recency) so disk doesn't creep to the capacity-probe warn line. Best-effort — it is not part of the plan hash or the dry-run preview, and a prune failure never fails a green deploy. ->pruneImages(false) disables it.
  • ->backupToolchainExternal(bool) — tells deploy:doctor's backup.toolchain check that the DB client toolchain runs on the backup role image, not this lean deploy image (see doctor checks).
  • ->autoPublishMigrations(bool) — opt-in: a live deploy publishes any un-published module migration stubs before the doctor gate (equivalent to the deploy --auto-publish flag). Off by default — the default posture is the fail-closed UnpublishedStubCheck refusing the deploy so you publish and commit deliberately.

Runtime service shape

The blue/green app and worker containers the cutover brings up are described by a runtime service spec — how the app actually runs. The defaults match the shipped FrankenPHP stub; override them when your image differs:

config/deploy.php
return static fn (DeploymentDefinitionBuilder $b) => $b
    ->appCommand(['frankenphp', 'run', '--config', '/etc/frankenphp/Caddyfile', '--adapter', 'caddyfile'])
    ->workerCommand(['/usr/bin/supervisord', '-c', '/etc/supervisord.conf'])
    ->workerTopology(WorkerTopology::RideColor)    // workers ride the compose color (default)
    ->containerPort(8080)                          // internal HTTP port; the edge dials app-<color>:8080
    ->envFiles(['/opt/vortos/.env.prod'])          // absolute paths mounted into each color
    ->appEnvironment(['SERVER_NAME' => ':8080']);  // extra app-service env (e.g. bind FrankenPHP to :8080)

The colors are internal-only — they publish no host ports and only expose containerPort so the standalone edge reverse-proxies to app-<color>:<containerPort> over the external vortos-net. This spec is the single source of truth for both the readiness gate and the edge upstream (the Caddy dial port comes from containerPort, never a hardcoded value), so a color that comes up healthy is the exact one the edge routes to. The image must ship a real server command (a bare frankenphp run …, not a placeholder) and be reachable at containerPort. See strategies for workerTopology.

The worker service always gets an explicit Docker healthcheck so it never inherits the app image's HTTP HEALTHCHECK (the FrankenPHP base image probes curl :2019/metrics, which the worker — running supervisord, serving no HTTP — can never pass, leaving it perpetually unhealthy). When workerCommand runs supervisord (the default), the framework emits a real supervisorctl status check that is healthy only when every managed program is RUNNING; a custom worker command gets an explicit disable instead. Override it with ->workerHealthcheck(WorkerHealthcheck::command([...])) when you have a bespoke liveness check.

Works out of the box on a stock container

A stock install now compiles and runs without app-side glue: deploy, deploy:doctor, and deploy:rollback always register (they print remediation and return failure if vortos-release/vortos-migration are missing, rather than silently vanishing); a Guzzle PSR-18/PSR-17 client is bound by default for the Caddy/OIDC/SSH-CA/registry clients if you haven't provided one; and the SSH-CA / registry-exchange endpoint parameters are set from env with safe empty defaults.

Pull-based delivery

Push over SSH-compose is the default. Pull-based delivery (the target polls a signed manifest, no inbound network) is opt-in:

VORTOS_DEPLOY_DELIVERY_MODE=pull
VORTOS_DEPLOY_PULL_REGISTRY_URL=ghcr.io
VORTOS_DEPLOY_PULL_REPOSITORY=acme/app
VORTOS_DEPLOY_PULL_RELEASE_PUBLIC_KEY=age1...

Only in pull mode are the OCI manifest source, release-key verifier, and reconciler registered. deploy:agent stays visible in push mode and fails loudly if invoked.

Config & secret delivery

The runtime files that live outside the image — .env.prod, docker-compose.prod.yaml, the mounted config trees (docker/**, observability/**) and the age-encrypted secrets store vortos-secrets.age — are shipped to the remote deploy dir by ArtifactDelivery over the SSH transport. A DeliveryManifest declares the file set (with a sane DeliveryManifest::default()), required artifacts fail closed before anything is sent, and the whole set is staged then swapped into place atomically so a partial transfer never leaves the box running against half-written config. Remote paths are traversal-guarded.

.env.prod ships owner-only 0600 (the docker CLI reads it as the deploy user via --env-file). The age store ships 0640 — the deploy-in-image one-shots read it as the image's runtime uid, which differs from the delivering user's, so an owner-only file would be unreadable; the remote docker run grants the container the store's group with --group-add so only that group, never the world, can read it. The store is age ciphertext (the KEK arrives via env, never on disk), so group-read leaks no plaintext.

File-shaped secrets

The immutable-image posture prefers env-content secrets (e.g. base64-PEM keys via vortos:auth:keys:generate --emit=env), so nothing is ever written to disk. When a secret genuinely must be a file (a tool that only reads an RS256 key from a path), declare a file secret instead of dropping the zero-plaintext-to-disk rule:

config/deploy.php
$b->fileSecret('jwt_private_key', '/run/secrets/jwt_private_key.pem');
// name in the age store → in-container mount path (tmpfs host path defaults to /run/vortos-secrets/<name>)

Before cutover the deploy one-shot decrypts each declared file secret from the age store to a tmpfs (RAM) path (vortos:deploy:materialize-file-secrets, written 0400 via an atomic temp+rename), and the cutover compose bind-mounts it read-only into the color at the declared path. Plaintext never touches persistent storage; host paths must be under /run or /dev/shm. deploy:doctor fails closed if a declared file secret is missing from the store.

First-deploy provisioning

vortos:deploy:provision idempotently prepares a fresh box before deploy:doctor runs: it generates RS256 JWT signing keys only when absent, applies pending migrations with vortos:migrate --force, then runs the fail-closed secrets preflight. Key presence mirrors config/auth.php's precedence — keys count as present when either the env-content keys (JWT_PRIVATE_KEY / JWT_PUBLIC_KEY, base64 PEM — the immutable-image posture) or the file paths (JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH) are set, so an env-content deploy is never wrongly told to generate file keys into a non-writable dir. It is safe to run on every deploy, and the generated deploy-on-target pipeline invokes it inside the image on the VPS first in the sequence, so its migration creates the release-ledger schema before record-manifest writes to it.

CLI

deploy reads the latest recorded build manifest for the environment, so vortos:release:record-manifest must have run for that build first (the generated pipeline does this for you). --image-repository / --image-digest override the manifest's image when you need to pin or promote a specific one by hand.

# rehearse: doctor + plan + preview, zero mutation
php bin/console deploy --env=production --dry-run

# deploy for real
php bin/console deploy --env=production --actor=alice

# publish any un-published module migration stubs before the doctor gate (live only; opt-in)
php bin/console deploy --env=production --auto-publish --actor=alice

# promote a specific image by hand (overrides the recorded manifest)
php bin/console deploy --env=production \
    --image-repository=ghcr.io/acme/app --image-digest=sha256:... --actor=alice

# resume an interrupted run — verifies the plan hash matches before continuing
php bin/console deploy --env=production --resume

# idempotent first-deploy provisioning: RS256 JWT keys (if absent) → migrate → secrets preflight
php bin/console vortos:deploy:provision --env=production --json

# fail-closed preflight — the same checks deploy runs first, on their own
php bin/console deploy:doctor --env=production --json

# roll back to the previous known-good build, or an explicit one
php bin/console deploy:rollback --env=production
php bin/console deploy:rollback --env=production --to=build-20260601-1 --actor=alice

# reconcile the edge router with the desired route (idempotent, rate-limited)
php bin/console deploy:reconcile --env=production

deploy:doctor runs automatically

deploy runs the same fail-closed preflight as deploy:doctor before touching infrastructure, every time — including on --resume. There is no way to skip preflight by accident; if you need to see what it checks without deploying, run deploy:doctor directly.

In --json mode, deploy keeps stdout pure machine JSON, and additionally writes the failing gate(s) and reason (or the rollback reason) to stderr when a deploy is refused or rolled back — so a CI log shows why without a manual non-JSON re-run.

Step-by-step: your first deploy

Define the environment:

config/deploy.php
use Vortos\Deploy\DependencyInjection\VortosDeployConfig;

return static function (VortosDeployConfig $config): void {
    $config->environment('production')
        ->target('ssh-compose')
        ->strategy('blue-green')
        ->credentialProvider('ssh-ca-oidc');
};

Check that everything required is actually in place — secrets, credentials, target reachability:

php bin/console deploy:doctor --env=production

Rehearse the plan with zero mutation:

php bin/console deploy --env=production --dry-run

Deploy:

php bin/console deploy --env=production --image-digest=sha256:abc... --actor=$(whoami)

If something's wrong, roll back — Deploy consults Release's rollback invariant automatically and refuses if the target schema isn't a safe subset of what's currently applied:

php bin/console deploy:rollback --env=production

Sections

On this page