The problem: a deploy that depends on one person's laptop

A team ships a Spring Boot service by building a jar on a developer's machine, copying it to a server and restarting it. One day the build uses a different Java version than usual, a test was skipped "just this once", and the rollback means finding the previous jar in someone's downloads folder. Nothing is wrong with the code; the delivery process is the weak point.

Continuous integration (CI) means every change is built and tested automatically in a clean environment. Continuous delivery (CD) means every change that passes can be released by a repeatable process. The goal is not speed for its own sake but a release that is boring: same steps, same result, easy to undo.

The core idea: build once, promote the same artifact

The most important rule of a delivery pipeline is that the artifact you test is the artifact you deploy. Build one image, identify it with an immutable tag (the commit SHA, not latest), and move that exact image through test, staging and production. Rebuilding for each environment means production runs something nobody tested.

Environment differences belong in configuration, not in the build. The same image gets a different datasource URL and different secrets in each place.

Stages and their purpose

commit -> build -> test -> checks -> image -> deploy staging
                                          -> smoke test
                                          -> approve -> deploy prod
  1. Build. Compile with a pinned JDK version and a wrapper (mvnw or gradlew) so the CI and the laptop use the same build tool version.
  2. Test. Fast unit tests first, then slower integration tests, for example with Testcontainers. Fail early: put the cheapest stage first so developers get feedback quickly.
  3. Static checks. Formatting, a linter or static analysis, and a dependency vulnerability scan. Decide which findings block the build; a scan that everybody ignores is worse than none.
  4. Image. Build the container image from the Dockerfile, tag with the commit SHA, push to a registry.
  5. Deploy and verify. Deploy to a staging environment, run a short smoke test against the health endpoint and one or two critical flows, and then promote.

A GitHub Actions-style example

name: cion:  pull_request:  push:    branches: [main]jobs:  build-test:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v      - uses: actions/setup-java@v        with:          distribution: temurin          java-version: 21          cache: maven      - run: ./mvnw -B verify  image:    needs: build-test    if: github.ref == 'refs/heads/main'    runs-on: ubuntu-latest    permissions:      contents: read      packages: write    steps:      - uses: actions/checkout@v      - run: docker build -t registry.example/app:${{ github.sha }} .      - run: docker push registry.example/app:${{ github.sha }}

The syntax differs between CI products, but the structure is the same everywhere: triggers, jobs with dependencies, caching, and permissions that are limited to what the job needs. Pin third-party actions or plugins to a version you have reviewed; a pipeline that pulls unpinned code is a supply-chain risk.

Caching

Downloading dependencies on every run wastes time. Cache the Maven or Gradle repository keyed by the hash of the build files (pom.xml, lock files). Cache Docker layers if your CI supports it. Be careful with caches: a stale or poisoned cache can make a build pass that would fail on a clean machine, so keep a way to run without cache, and never cache secrets or build outputs that should be fresh.

Environment promotion and rollbacks

Staging should resemble production in the ways that matter: the same database engine and version, the same deployment mechanism, similar configuration shape. Database migrations deserve special care. A migration that drops a column breaks the previous application version, so a rollback of code is no longer safe. The usual approach is expand and contract: first add the new column and deploy code that writes both, then later remove the old one after nothing reads it.

A rollback should be a deploy of a previous known image tag, not a rebuild. If you can do this with one command or a button and you have practised it, rollbacks stop being frightening. Pair it with progressive release techniques where the platform supports them: rolling updates, a small percentage of traffic first (canary), or blue/green. Each needs good health checks and observability to be useful, see observability: logs, metrics and traces.

Feature flags are another tool: they separate deploying code from enabling behaviour, so you can turn a risky feature off without redeploying.

Secrets in CI

  • Store secrets in the CI platform's secret store or an external vault, never in the repository or in the image.
  • Give each job the minimum access it needs. A pull request from a fork should not have access to deployment credentials.
  • Prefer short-lived credentials obtained through federation (OIDC between the CI and the cloud) over long-lived keys.
  • Do not print secrets in logs. Masking helps but can miss values, so avoid echoing environment variables.
  • Rotate a secret immediately if it ever appears in a log or a commit.

Pull requests, branches and feedback speed

Run the build and tests on every Pull Request, and make a passing pipeline a condition for merging into the main branch. This is the cheapest quality gate a team has, because it checks every change the same way regardless of who wrote it. Keep the main branch always releasable: if it breaks, fixing it is the team's first priority.

Short-lived branches and small changes keep the pipeline useful. A branch that lives for weeks collects merge conflicts and surprises. Aim for a feedback loop in which a developer learns within minutes whether a change is acceptable; when it takes much longer, people batch their work and the benefits of CI fade. Publish test reports and logs as pipeline artifacts so a failure can be understood without reproducing it locally.

Common mistakes

  • Rebuilding the artifact per environment.
  • Tagging images latest and not knowing what runs in production.
  • Allowing "skip tests" flags or manual merges that bypass the pipeline.
  • Slow pipelines that people learn to work around. Split, parallelise and cache before accepting a long feedback loop.
  • Flaky tests that are re-run until green. Fix or quarantine them with an owner and a deadline.
  • Running database migrations manually and out of order.
  • A deploy step with no verification and no rollback plan.

Trade-offs

Full continuous deployment to production on every merge needs strong tests, good observability and fast rollback. Many teams are better served by continuous delivery with a manual approval step until that trust exists. Heavy approval gates, on the other hand, create big batches of changes, and big batches are riskier than many small ones. Choose the amount of ceremony according to the cost of a failure for your system, and revisit it as the system matures.

Checklist

  • Pinned JDK and build tool wrapper.
  • Cheap tests first, integration tests as a separate stage.
  • Image tagged with the commit SHA and promoted unchanged.
  • Dependency cache keyed by build files.
  • Secrets from a store, with least privilege and short-lived credentials.
  • Backward-compatible migrations and a practised rollback.
  • Smoke test after every deploy.

Where to go next