Continuous Deployment

Build one image per commit, apply the manifest, wait for the rollout to finish, and keep the migration a separate gated step.

Syntaxkubectl rollout status deployment/app --timeout=300s

Continuous deployment means a commit that passes CI reaches production without anyone typing commands on a server. The pipeline does four things: build an image, point the deployment at it, wait, and check.

Tag by commit, never by name

The image tag is the commit SHA. app:9f3c1ab identifies exactly one build, so you can tell which code is running and roll back by naming an older tag. A moving tag such as latest makes that impossible, and it also breaks the deploy in a quieter way: if the tag in the manifest does not change, the platform sees no change and replaces no pods.

Wait, then verify

Applying a manifest returns immediately; it only records the desired state. kubectl rollout status deployment/app --timeout=300s blocks until the new pods are available, and returns non-zero if they never become ready. Follow it with one request to an endpoint that reports the build the running code came from. That is the difference between a deploy that was accepted and a deploy that is serving.

The migration is its own step

Schema changes are not part of the rolling update, because during a rollout old and new pods serve at the same time. Run migrations as a separate job before the new image goes live, and only ones the old code tolerates: add columns, do not drop them. Gating means the pipeline stops and waits for a human when the step is destructive. Generate the SQL with --dry-run --write-sql, put it in the pull request, and require an approval.

Example

Example · php
<?php
// src/Controller/VersionController.php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

/**
 * Reports which build is serving, so the pipeline can confirm the rollout
 * actually replaced the pods instead of trusting that it did.
 */
final class VersionController extends AbstractController
{
    public function __construct(
        // Baked into the image at build time as an environment variable.
        #[Autowire('%env(APP_BUILD_SHA)%')]
        private readonly string $buildSha,
    ) {
    }

    #[Route('/_version', name: 'app_version', methods: ['GET'])]
    public function __invoke(): JsonResponse
    {
        return new JsonResponse(
            ['sha' => $this->buildSha],
            200,
            ['Cache-Control' => 'no-store'],
        );
    }
}

// GET /_version returns 200 with {"sha":"9f3c1ab"}.
// The deploy job requests it after the rollout and fails if the SHA
// is not the commit it just built.

When to use it

  • Ship a merged pull request to production with no manual server access.
  • Roll back by redeploying the previous commit's image tag.
  • Hold a destructive migration until a reviewer approves the generated SQL.
  • Fail the pipeline when the new pods never become ready.

More examples

The deploy job

Each step either succeeds or stops the job. kubectl set image changes only the tag, which is enough to trigger a rolling update; a full kubectl apply -f deploy/ is right when other fields changed too. The final check is what catches the case where the rollout reported success and then the new pods started failing: rollout status returns as soon as the new replica set is available, not after any particular length of healthy service.

Example · yaml
# .github/workflows/deploy.yaml
name: Deploy
on:
  push:
    branches: [main]

# Least privilege: read the repo, mint a short-lived cloud token, nothing else.
permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      IMAGE: registry.example.com/app
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      # linux/amd64 explicitly: the runner's architecture is not always the cluster's.
      - run: |
          docker buildx build \
            --platform linux/amd64 \
            --build-arg APP_BUILD_SHA="$GITHUB_SHA" \
            --tag "$IMAGE:$GITHUB_SHA" \
            --push .

      - run: kubectl set image deployment/app php="$IMAGE:$GITHUB_SHA"
      - run: kubectl rollout status deployment/app --timeout=300s

      # Confirm the running code is the commit we just built.
      - run: |
          test "$(curl -fsS https://app.example.com/_version | jq -r .sha)" = "$GITHUB_SHA"

Migrations as a job, before the rollout

Run this with kubectl apply -f deploy/migrate-job.yaml and then kubectl wait --for=condition=complete job/migrate-9f3c1ab --timeout=600s, before changing the deployment's image. It uses the new image, so the migration files are the ones the new code expects. backoffLimit: 0 stops Kubernetes from retrying a half-applied migration. Because the old pods are still serving while this runs, every migration in it has to be one the old code can live with.

Example · yaml
# deploy/migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  # Unique per commit: a Job's pod template cannot be changed once created.
  name: migrate-9f3c1ab
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: registry.example.com/app:9f3c1ab
          command:
            - php
            - bin/console
            - doctrine:migrations:migrate
            - --no-interaction
          envFrom:
            - secretRef:
                name: app-env

Reviewing the SQL before it runs

This is the gate. The pipeline runs the dry run on every pull request and attaches the SQL, so a DROP COLUMN or a table rewrite is seen by a person before it reaches production. up-to-date is the machine-readable form: it exits non-zero when migrations are pending, which lets a deploy refuse to proceed rather than serve new code against an old schema.

Example · bash
# Print the statements without touching the database.
php bin/console doctrine:migrations:migrate --dry-run --no-interaction

# Or capture them for a reviewer to read in the pull request.
php bin/console doctrine:migrations:migrate \
    --no-interaction \
    --write-sql=var/pending-migration.sql

# What is still unapplied in this environment?
php bin/console doctrine:migrations:status
php bin/console doctrine:migrations:up-to-date

Discussion

  • Be the first to comment on this lesson.