Continuous Deployment: Shipping a CI Artifact Safely to Production

Explain what happens after a CI pipeline produces a build artifact — deploying it to servers, running database migrations safely, verifying health before routing traffic, rolling back quickly on failure, achieving zero-downtime releases, and injecting secrets at deploy time instead of baking them into the artifact.

Continuous Integration answers the question 'is this code good?' by building, testing, and packaging it into an artifact — a Docker image, a zipped tarball of vendor/ and source files, or a signed release bundle. Continuous Deployment (CD) answers the very next question: 'how does that artifact safely become the thing running in production, without downtime and without risking a broken app in front of real users?' CD is where a lot of engineering discipline lives, because unlike a failed CI build (which only inconveniences developers), a bad deploy is customer-facing. This lesson walks through the concerns every real CD pipeline has to handle for a PHP application: getting the artifact onto servers, running migrations without breaking the currently-running version, verifying the new version is actually healthy before sending it traffic, being able to roll back in seconds, achieving zero downtime, and pulling secrets from a vault instead of committing them.

From Artifact to Running Servers

By the time CD takes over, CI has already produced something immutable and versioned — commonly a Docker image tagged with the Git commit SHA (e.g. myapp:a1b2c3d), though older PHP shops sometimes still ship a tarball of the codebase plus a vendor-locked composer install --no-dev output. The deploy step's job is to get that exact artifact running on the target servers, and 'exact' matters enormously: if staging tested a1b2c3d and production somehow ends up running a rebuild of the same source (rather than the same artifact), you've reintroduced the very non-determinism CI was built to eliminate. This is why mature pipelines deploy by reference to an immutable artifact — a Docker image digest or a build number — never by re-running composer install and npm run build again on the production host.

The mechanics of 'getting it there' vary by target. For a container-orchestrated environment (Kubernetes, ECS, Docker Swarm), deploying means updating a deployment manifest or task definition to point at the new image tag and letting the orchestrator perform a rolling update. For traditional VM/bare-metal fleets, deploying often means pulling the new artifact onto each server (via rsync, scp, or a pull from an artifact registry) into a fresh, timestamped release directory, then atomically swapping a symlink (current -> releases/2024-05-01-1200/) so the web server's document root changes in one filesystem operation rather than mid-edit. Tools like Deployer (a popular PHP-specific deployment tool) and Capistrano formalize exactly this symlink-swap release layout.

Whichever mechanism is used, the deploy step should be idempotent and repeatable: running it twice with the same artifact should produce the same end state, and a partially-failed deploy should be detectable rather than silently leaving some servers on the old version and some on the new indefinitely (a 'split-brain' fleet, which causes confusing, inconsistent bugs for users hitting different servers).

• CD deploys an immutable, already-tested artifact — it never rebuilds source on the production host. • Container targets: update the orchestrator's manifest/task definition to the new image tag. • VM/bare-metal targets: pull artifact into a new release directory, then atomically symlink-swap current. • Deploys must be idempotent and must not leave the fleet in an inconsistent split-brain state.

Database Migrations as Part of a Deploy

Most non-trivial PHP applications ship schema changes alongside code changes, and a CD pipeline typically runs migrations (via a tool like Doctrine Migrations, Phinx, or a framework's built-in migrator such as Laravel's artisan migrate) as an explicit pipeline step, usually right before the new code goes live. The tricky part is timing: for even a few seconds during a rolling deploy, both the old code and the new code may be running simultaneously against the same database, so a migration that isn't backward-compatible can break the old version that's still serving traffic.

The safe pattern is the 'expand/contract' migration, split across multiple deploys. To rename a column, for example: deploy 1 adds the new column alongside the old one and starts dual-writing to both from the application code (expand); a later deploy backfills existing rows and switches reads to the new column; only in a final deploy, once you're certain no code anywhere still reads the old column, do you drop it (contract). Each of these three steps is independently backward-compatible with the code version both immediately before and immediately after it, so a rolling deploy or even a rollback mid-sequence never leaves the app querying a column that doesn't exist yet or has already vanished.

Migrations should also run inside a transaction where the database supports DDL transactions, be applied automatically as a pipeline step (never as a manual 'someone SSHes in and runs it' step, which is exactly the kind of undocumented, error-prone action CD exists to eliminate), and be tracked in a migrations table so the pipeline can tell which migrations have already run on a given database and skip them idempotently on a re-deploy.

• Run migrations as an explicit, automated pipeline step — never a manual SSH command. • Non-backward-compatible schema changes must be split: expand (add/dual-write) → migrate/backfill → contract (drop old). • During a rolling deploy, both old and new code may briefly query the same database — migrations must tolerate that. • A migrations-run table keeps re-deploys idempotent — already-applied migrations are skipped.

Health Checks Before Routing Traffic

A deploy that merely 'starts' the new version is not the same as a deploy that has confirmed the new version actually works. Production-grade CD pipelines add an explicit health-check gate between 'new version is running' and 'new version receives real user traffic'. This usually means exposing a lightweight /healthz (or /health) HTTP endpoint from the PHP application that checks the things that would make the app unable to serve requests correctly — can it reach the database, can it reach Redis, is the disk not full, are required environment variables present — and returns a fast 200 OK only when all of those are true.

Orchestrators formalize this with two related but distinct probes. A liveness check asks 'is this process alive at all, or should it be killed and restarted' — it should be extremely cheap and rarely fail unless the process is genuinely wedged. A readiness check asks 'is this instance currently able to serve real traffic' — it can fail transiently (e.g. during a slow startup while a cache warms) without the process being killed; the orchestrator simply withholds traffic from that instance until readiness passes. A PHP-FPM container behind Kubernetes, for instance, typically has a liveness probe that just confirms the FPM master process responds, and a separate readiness probe that hits an application-level /healthz endpoint verifying DB connectivity.

In a scripted (non-Kubernetes) deploy, the same idea is implemented directly in the deploy script: after starting or restarting the new version on a server, the script polls its health endpoint in a loop with a timeout, and only proceeds to add that server back into the load balancer's pool (or move on to the next server in the fleet) once several consecutive checks succeed. A deploy that never checks health is really just 'ready or not, here comes traffic' — exactly the kind of gamble CD is meant to remove.

• Liveness: is the process alive? (cheap, restart-triggering if it fails) • Readiness: can this instance serve real traffic right now? (gates whether it receives traffic) • A /healthz endpoint should check real dependencies: database, cache, disk, required config. • A deploy script should poll health with a timeout before adding a server back to the load balancer.

Zero-Downtime Deployment and Rollback

Zero-downtime deployment means users never see an error or a blank page during a release, which requires that the old version keeps serving traffic until the new version is confirmed healthy, and that the switch from old to new is atomic from the load balancer's point of view. The two dominant patterns are rolling deployment (replace instances a few at a time, always keeping enough old-version capacity up to handle load, common in Kubernetes rolling updates) and blue-green deployment (run a complete second environment — 'green' — alongside the live 'blue' one, health-check it fully, then flip a router/load-balancer to send all traffic to green at once, keeping blue warm as an instant fallback). Blue-green gives the fastest, cleanest rollback at the cost of running double the infrastructure during the switch; rolling deployment is cheaper but makes 'in-between' states (some servers old, some new) unavoidable for a window of time.

Rollback strategy has to be decided before you need it, not during an incident. The fastest possible rollback is simply re-pointing at the previous known-good artifact — this is exactly why deploying by immutable, versioned artifact matters: rolling back a Kubernetes deployment to the prior image tag, or re-symlinking current back to the previous release directory, takes seconds because no rebuild is involved. The database is the harder half of rollback: if a deploy included a migration, rolling back the code without rolling back the schema can break the old code (which is exactly why the expand/contract pattern from the previous section matters — each step is designed so a rollback to the immediately-prior code version still works against the current schema).

A good CD pipeline treats rollback as a first-class, tested, one-command operation — not an emergency improvisation — and keeps enough previous release artifacts and images retained (not immediately garbage-collected) to make that possible at any moment.

• Rolling deploy: replace instances gradually, always keeping enough old-version capacity serving traffic. • Blue-green deploy: stand up a full parallel environment, health-check it, then flip traffic at once. • Rollback should mean 're-point at the previous immutable artifact', not 're-run a build'. • Schema rollback is the hard part — expand/contract migrations keep old code compatible with the current schema.

Secret Injection at Deploy Time

Secrets — database passwords, API keys, encryption keys, third-party credentials — must never be committed to source control and, just as importantly, should not be baked into the build artifact itself. An artifact with secrets embedded in it (e.g. a .env file COPY'd into a Docker image) means anyone with pull access to that image, including in an old, supposedly-retired version sitting in a registry, has the production database password forever, and rotating that secret requires rebuilding and redeploying every artifact that ever contained it.

The safer pattern is secret injection at deploy or runtime: the artifact itself contains no secrets, only the code that knows how to fetch them, and the actual secret values are pulled from a dedicated secret manager (HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, or a platform's built-in secrets store like Kubernetes Secrets or a CI provider's encrypted variables) at the moment the container starts or the deploy script runs, and injected as environment variables or a runtime-mounted file. This means rotating a compromised secret is a change in one place (the vault) rather than a rebuild-and-redeploy of every service that uses it, and it means the artifact remains genuinely identical across dev, staging, and production — only the injected secret values differ per environment.

In a PHP app this typically looks like reading configuration through getenv() or a config library that reads environment variables ($_ENV['DB_PASSWORD']), with those environment variables populated by the deploy tooling immediately before the PHP-FPM/CLI process starts — never hardcoded, and never git commit'd even in an 'example' file with real values accidentally left in.

• Never bake secrets into the build artifact — an old image in the registry would leak them forever. • Pull secrets from a vault/secret manager at deploy or container-start time. • Rotating a secret should mean updating the vault, not rebuilding and redeploying the artifact. • PHP apps read injected secrets via environment variables (getenv(), $_ENV), never hardcoded values.

A Minimal Deploy Script Shape

The secret is fetched at deploy time from Vault and injected as an env var — it never appears in the Docker image. set -euo pipefail makes the script fail loudly on any error instead of silently continuing a broken deploy.

#!/usr/bin/env bash
set -euo pipefail

ARTIFACT_TAG="$1"   # e.g. the Git SHA, passed in by CI
SECRET_DB_PASSWORD="$(vault kv get -field=password secret/prod/db)"

docker pull "registry.example.com/myapp:${ARTIFACT_TAG}"
docker run -d --name myapp-new \
  -e DB_PASSWORD="${SECRET_DB_PASSWORD}" \
  "registry.example.com/myapp:${ARTIFACT_TAG}"

Code Examples

This is a scripted blue-green health-gate: the new version starts on a separate port with no live traffic, the script polls its own /healthz endpoint up to 30 times, and only calls switch-traffic.sh — which repoints the load balancer — once several checks confirm the new version is actually able to serve requests. If health never passes, the script tears the new container down and exits with a failure, so a broken deploy never reaches real users.

until curl -fsS "$HEALTH_URL" | grep -q '"status":"ok"'; do ... done

This endpoint does more than confirm 'PHP is running' — it actively tests the database and Redis connections the application depends on, with short timeouts so a slow dependency doesn't hang the health check itself. It responds 503 (not 200) when unhealthy, which matters because many load balancers and orchestrators specifically key readiness off the HTTP status code, not the response body.

Splitting the change across three deploys means that at every single point in time — including mid-rollout of a rolling deploy, and including a rollback to the immediately-prior version — the running code's expectations match the current schema. Doing the rename in one migration would break any old-version instance still running during a rolling deploy, since it would suddenly be missing the email column it still expects.

#!/usr/bin/env bash
set -euo pipefail

NEW_VERSION="$1"
HEALTH_URL="http://localhost:8081/healthz"
MAX_ATTEMPTS=30

echo "Starting new version: ${NEW_VERSION}"
docker run -d --name myapp-green -p 8081:8080 "myapp:${NEW_VERSION}"

echo "Waiting for /healthz to report healthy..."
attempt=0
until curl -fsS "$HEALTH_URL" | grep -q '"status":"ok"'; do
  attempt=$((attempt + 1))
  if [ "$attempt" -ge "$MAX_ATTEMPTS" ]; then
    echo "Health check failed after ${MAX_ATTEMPTS} attempts. Aborting deploy."
    docker rm -f myapp-green
    exit 1
  fi
  sleep 2
done

echo "New version is healthy. Switching load balancer to green."
./switch-traffic.sh --to green
docker rm -f myapp-blue || true
mv myapp-green myapp-blue 2>/dev/null || true
echo "Deploy complete."
<?php
declare(strict_types=1);

header('Content-Type: application/json');

$checks = [];
$healthy = true;

try {
    $pdo = new PDO(
        'mysql:host=' . getenv('DB_HOST') . ';dbname=' . getenv('DB_NAME'),
        getenv('DB_USER'),
        getenv('DB_PASSWORD'),
        [PDO::ATTR_TIMEOUT => 2]
    );
    $pdo->query('SELECT 1');
    $checks['database'] = 'ok';
} catch (PDOException $e) {
    $checks['database'] = 'unreachable';
    $healthy = false;
}

$redis = new Redis();
try {
    $redis->connect(getenv('REDIS_HOST'), 6379, 1.0);
    $checks['redis'] = $redis->ping() ? 'ok' : 'unreachable';
    if ($checks['redis'] !== 'ok') {
        $healthy = false;
    }
} catch (RedisException $e) {
    $checks['redis'] = 'unreachable';
    $healthy = false;
}

http_response_code($healthy ? 200 : 503);
echo json_encode([
    'status' => $healthy ? 'ok' : 'degraded',
    'checks' => $checks,
]);
// Deploy N: expand — add the new column, dual-write to both
public function up(): void
{
    $this->addColumn('users', 'email_normalized', 'string', ['null' => true]);
}

// Application code during deploy N (dual-write):
$user->email = $input['email'];
$user->email_normalized = strtolower(trim($input['email']));
$user->save();

// Deploy N+1: backfill existing rows, switch reads to the new column
public function up(): void
{
    $this->execute(
        'UPDATE users SET email_normalized = LOWER(TRIM(email))
         WHERE email_normalized IS NULL'
    );
}

// Deploy N+2: contract — only after confirming nothing reads the old column
public function up(): void
{
    $this->removeColumn('users', 'email');
}

CD Pipeline: Artifact to Live Traffic

Traffic only moves to the new version after it proves itself healthy — a failed check aborts the deploy instead of exposing users to a broken instance.

CI artifact          Deploy               Health gate           Traffic switch
 ┌───────────┐   pull  ┌───────────┐  poll  ┌─────────────┐  pass ┌───────────────┐
 │ myapp:a1b2 │ ──────▶ │ new instance│ ─────▶ │ /healthz OK? │ ────▶ │ router → green │
 └───────────┘         └───────────┘         └─────────────┘        └───────────────┘
                                                     │ fail
                                                     ▼
                                             ┌─────────────────┐
                                             │ abort, tear down, │
                                             │ old version stays │
                                             └─────────────────┘

Important Rules

Deploy immutable, already-tested artifacts by reference — never rebuild source on a production host.

Split backward-incompatible schema changes into expand → migrate → contract steps across separate deploys.

Never route real traffic to a new version until its readiness/health check passes.

Treat rollback as a first-class, one-command operation, not an improvised emergency response.

Inject secrets from a vault/secret manager at deploy or runtime — never bake them into the artifact.

Vault-Backed Secret Rotation

Teams using HashiCorp Vault commonly configure short-lived, automatically-rotating database credentials: the deploy pipeline requests a fresh, time-limited password from Vault on every deploy rather than reading a static one, so even a leaked credential expires on its own within hours and no manual rotation process is ever needed.

// Deploy 1: add email_address, dual-write both // Deploy 2: backfill + switch reads // Deploy 3: drop email

A single-step rename breaks any old-version instance still serving traffic during a rolling deploy (or any rollback target), because it suddenly loses a column it still queries by name.

COPY .env /var/www/html/.env # secrets baked into the Docker image

Baking secrets into the image means every copy of that image ever pulled — including old, retired versions sitting in a registry — permanently contains the production credentials, and rotating them requires rebuilding and redeploying everywhere.

docker run -d myapp:latest && update-load-balancer # no health check at all docker run -d myapp:latest && wait-for-healthz && update-load-balancer

Routing traffic immediately after starting a container, with no health verification, means a broken deploy (crashed process, unreachable database, missing config) is discovered by real users instead of by the pipeline.

Why should a CD pipeline deploy by referencing an immutable, already-built artifact rather than rebuilding from source on the production host? A. Rebuilding on production is always faster B. It guarantees the exact thing that was tested in CI is the exact thing running in production C. PHP cannot be built outside of a production server D. It avoids the need for a health check

B. It guarantees the exact thing that was tested in CI is the exact thing running in production — If production rebuilds from source instead of running the tested artifact, you lose the guarantee that what was tested is what's running — reintroducing exactly the non-determinism CI is meant to eliminate.

What is the key difference between a liveness check and a readiness check? A. They are two names for the exact same check B. Liveness asks whether the process should be restarted; readiness asks whether it should currently receive traffic C. Readiness only runs once at startup; liveness runs continuously D. Liveness checks the database; readiness checks the disk

B. Liveness asks whether the process should be restarted; readiness asks whether it should currently receive traffic — Liveness determines whether the process is wedged and should be killed/restarted; readiness determines whether an otherwise-alive instance is currently able to serve real traffic, gating whether the load balancer sends it requests.

Why is the expand/contract pattern used for a schema change like renaming a column? A. It's faster to write than a single ALTER TABLE statement B. It ensures both the old and new code versions remain compatible with the schema at every point during a rolling deploy or rollback C. It avoids needing a migrations table D. MySQL does not support renaming columns at all

B. It ensures both the old and new code versions remain compatible with the schema at every point during a rolling deploy or rollback — Splitting the change into add/dual-write, backfill/switch-reads, and drop means that whichever code version happens to be running at any moment — including mid-rollout or after a rollback — finds a schema it can still work with.

Where should production secrets like database passwords come from at deploy time? A. Hardcoded directly in the application source code B. Baked into the Docker image via a COPY instruction C. A secret manager or vault, injected as environment variables when the container starts D. A .env file committed to the Git repository

C. A secret manager or vault, injected as environment variables when the container starts — Pulling secrets from a vault at deploy/runtime keeps them out of the artifact entirely, so rotating a compromised secret is a one-place change rather than a rebuild-and-redeploy of every service.

Sketch (in words or pseudocode) a deploy script for a PHP-FPM app that: (1) pulls a new Docker image tagged with a Git SHA, (2) runs database migrations before switching traffic, (3) starts the new container and polls a /healthz endpoint up to 20 times with a 3-second delay, and (4) only updates the load balancer if health checks pass, otherwise tears down the new container and exits non-zero.

docker pull myapp:$SHA php artisan migrate --force docker run -d --name myapp-new -p 8081:8080 myapp:$SHA for i in $(seq 1 20); do curl -fsS http://localhost:8081/healthz && break sleep 3 done if curl -fsS http://localhost:8081/healthz; then ./switch-lb.sh --to myapp-new else docker rm -f myapp-new exit 1 fi

Structure it as: pull → migrate → start (no traffic yet) → health-poll loop → switch-or-abort.