Jenkins competes on control and GitLab on breadth. CircleCI competes on one thing only: making the pipeline fast. It is a specialised SaaS — it does not host your code, it does not manage your issues, it is not your container registry — that assumes the repository lives somewhere else and concentrates on running jobs quickly, with good tooling for finding out why they are not. That specialisation shows in three pieces no other tool in the module has honed as finely: an explicit cache where you decide the key, when it is saved and how it degrades; orbs, which are versioned, publishable configuration packages; and test splitting by historical timings, which is the most mature answer to the sharding problem we have been carrying since 02-04 and which 04-04 could only solve by hand. In this lesson we translate the Reservalia pipeline for the third time, draw a precise distinction between workspace, cache and artifacts — three concepts that here are three separate, explicit mechanisms — and evaluate the price of that speed: dependence on a SaaS, a credit model you have to understand before signing, and a smaller ecosystem.

Contents

  1. The proposition: a SaaS specialised in speed
  2. Execution model: jobs, workflows and executors
  3. The Reservalia pipeline in .circleci/config.yml
  4. Explicit cache: keys, restore-keys and degradation
  5. Workspace, cache and artifacts: three different things
  6. Parallelisation and test splitting by historical timings
  7. Reuse: commands, parameters and orbs
  8. Contexts, secrets and approvals
  9. Resources, machine classes and the credit model
  10. Debugging: SSH into build and local execution
  11. Limits and trade-offs
  12. When to choose CircleCI
  13. Common Mistakes and Tips
  14. Exercises
  15. Conclusion

  1. The proposition: a SaaS specialised in speed

CircleCI was born in 2011, in the same wave as Travis, with a difference of emphasis that proved decisive: while Travis bet on extreme simplicity for open source projects, CircleCI bet on paying teams and on pipeline performance as the product. Hence its present shape.

What the specialisation implies, for better and for worse:

Consequence
It does not host the code It connects to GitHub, GitLab or Bitbucket. Your identity and permissions still live there, and CircleCI asks for access to the repository
It is not a registry or an issue tracker It publishes to ECR, Docker Hub or wherever you like: nothing given away, but nothing tying you down
All the investment is in execution Fine-grained resource classes, fast start-up, controllable cache, splitting by timings, good views of where the time goes
If the connection to the SaaS disappears, there is no CI The control plane is theirs, even with your own runners

And one cultural virtue worth pointing out: for years CircleCI has been among the tools that best expose where the time goes — time per job, per step, queues, cache hit rates, distribution of test durations. That matters because 04-04 established that optimising without measuring is guessing; here the measurement comes as standard.

  1. Execution model: jobs, workflows and executors

There is a single file: .circleci/config.yml, versioned with the code. Its structure has three levels.

flowchart TD
    W["workflow<br/>orchestrates and orders"] --> J1["job: prepare"]
    J1 --> J2["job: quality"]
    J1 --> J3["job: test<br/>parallelism: 4"]
    J1 --> J4["job: build"]
    J2 --> J5["job: publish"]
    J3 --> J5
    J4 --> J5
    J5 --> A{"hold<br/>type: approval"}
    A --> J6["job: deploy-production"]
    subgraph EX["executor: where each job runs"]
      E1["docker"]
      E2["machine"]
      E3["macos"]
      E4["arm"]
    end
CircleCI Equivalent in the course Nuance
Workflow The job graph There can be several in one file, with different triggers
Job Job A unit that runs on an executor; returns success or failure
Step Step run, checkout, save_cache, store_artifacts
Executor Runner type docker, machine, macos, windows, and ARM/GPU variants
Command Composite action A reusable sequence of steps, with parameters
Orb Package of templates Published and versioned in a public or private registry
Context Group of shared secrets Organisation scope, not project scope
Workspace Passing files between jobs Different from cache and from artifacts

Two characteristic traits of the model. First, the graph is explicit from the start: there is no concept of a "stage" acting as a barrier, only requires. CircleCI never had to evolve from phases to a graph because it was born with a graph. Second, executors are first class: they are declared at the top, with a name, and jobs reference them; switching the whole fleet from docker to machine is editing one block.

  1. The Reservalia pipeline in .circleci/config.yml

Third translation of the same pipeline.

version: 2.1

orbs:                                                   # 1
  aws-cli: circleci/aws-cli@5.1.0
  node: circleci/node@6.1.0

executors:                                              # 2
  node-base:
    docker:
      - image: cimg/node:22.11                          # "convenience" images prepared by CircleCI
        auth: { username: $DOCKERHUB_USER, password: $DOCKERHUB_TOKEN }
      - image: cimg/postgres:16.2                       # 3 · secondary containers of the same job
        environment:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: test
          POSTGRES_DB: reservalia_test
    resource_class: medium                              # 4
    working_directory: ~/reservalia
  docker-builder:
    machine:
      image: ubuntu-2404:current                        # full VM: there is a real Docker daemon
      docker_layer_caching: true                        # 5

commands:                                               # 6
  prepare-node:
    description: Checkout, cache restore and reproducible install
    parameters:
      depth:
        type: integer
        default: 1
    steps:
      - checkout
      - restore_cache:                                  # 7
          keys:
            - npm-v2-{{ checksum "package-lock.json" }}
            - npm-v2-                                   # partial fallback
      - run:
          name: Install dependencies
          command: npm ci --prefer-offline --no-audit

jobs:
  prepare:
    executor: node-base
    steps:
      - prepare-node
      - save_cache:                                     # 8 · only this job writes the cache
          key: npm-v2-{{ checksum "package-lock.json" }}
          paths: [ ~/.npm ]
      - persist_to_workspace:                           # 9
          root: ~/reservalia
          paths: [ ".", "!node_modules" ]

  quality:
    executor: node-base
    steps:
      - attach_workspace: { at: ~/reservalia }
      - run: npx prettier --check .
      - run: npm run lint
      - run: npm run typecheck

  test:
    executor: node-base
    parallelism: 4                                      # 10
    steps:
      - attach_workspace: { at: ~/reservalia }
      - run:
          name: Wait for PostgreSQL
          command: dockerize -wait tcp://localhost:5432 -timeout 1m
      - run: npm run migrate
      - run:
          name: Run this container's share of the tests
          command: |
            FILES=$(circleci tests glob "apps/**/*.test.ts" \
              | circleci tests split --split-by=timings)   # 11 · split by real timings
            npm test -- --runTestsByPath $FILES \
              --reporters=default --reporters=jest-junit
      - store_test_results: { path: reports }            # 12 · feeds the timing history
      - store_artifacts: { path: coverage }              # 13

  build:
    executor: docker-builder
    steps:
      - attach_workspace: { at: ~/reservalia }
      - run:
          name: Build image
          command: |
            docker buildx build \
              --file apps/api/Dockerfile \
              --cache-from type=registry,ref=$ECR/$IMAGE:cache \
              --cache-to   type=registry,ref=$ECR/$IMAGE:cache,mode=max \
              --tag $IMAGE:$CIRCLE_SHA1 --load .
      - run:
          name: Scan image
          command: trivy image --severity HIGH,CRITICAL --exit-code 1 $IMAGE:$CIRCLE_SHA1

  publish:
    executor: docker-builder
    steps:
      - attach_workspace: { at: ~/reservalia }
      - aws-cli/setup:                                   # 14 · OIDC, no long-lived keys
          role_arn: $AWS_ROLE_ARN
          region: eu-west-1
      - run:
          name: Publish to ECR by digest
          command: |
            aws ecr get-login-password --region eu-west-1 \
              | docker login --username AWS --password-stdin "$ECR"
            docker tag  $IMAGE:$CIRCLE_SHA1 $ECR/$IMAGE:$CIRCLE_SHA1
            docker push $ECR/$IMAGE:$CIRCLE_SHA1
            DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' $ECR/$IMAGE:$CIRCLE_SHA1)
            echo "$DIGEST" | tee digest.txt
      - store_artifacts: { path: digest.txt }

  deploy:
    executor: node-base
    parameters:
      environment: { type: enum, enum: [staging, production] }   # 15
    steps:
      - attach_workspace: { at: ~/reservalia }
      - aws-cli/setup: { role_arn: $AWS_ROLE_ARN, region: eu-west-1 }
      - run: ./scripts/deploy.sh << parameters.environment >>

workflows:
  ci:
    jobs:
      - prepare
      - quality: { requires: [prepare] }                 # 16
      - test:    { requires: [prepare] }
      - build:   { requires: [prepare] }
      - publish:
          requires: [quality, test, build]
          context: [aws-ci]                               # 17
          filters: { branches: { only: main } }
      - deploy:
          name: deploy-staging
          environment: staging
          requires: [publish]
          context: [aws-staging]
          filters: { branches: { only: main } }
      - approve-production:                               # 18
          type: approval
          requires: [deploy-staging]
          filters: { branches: { only: main } }
      - deploy:
          name: deploy-production
          environment: production
          requires: [approve-production]
          context: [aws-production]
          filters: { branches: { only: main } }
  1. Orbs are imported at the top and pinned to a version. circleci/aws-cli@5.1.0 is a published package; its contents are commands, jobs and executors already written. Pinning to an exact version is the same rule here as pinning actions by SHA in 04-03: an orb is third-party code that runs with your secrets.
  2. Named executors avoid repeating the environment configuration in every job. It is the trait that keeps the file cleanest.
  3. Secondary containers are the equivalent of services in GitLab or GitHub: they share the network with the primary one, so PostgreSQL is at localhost:5432. Important detail: CircleCI does not wait for them to be ready, hence the step with dockerize -wait. It is a common cause of flakiness early on.
  4. resource_class chooses the machine size (small, medium, large, xlarge…). It is the direct lever of 04-04: moving up a class shortens the job and multiplies credit consumption per minute. You decide by measuring, not by intuition.
  5. docker_layer_caching keeps Docker layers between runs on the machine executor. It speeds image builds up a lot and consumes additional credits; with a layer cache in the registry (--cache-from, 06-05) it sometimes is not worth it. You have to measure both options.
  6. commands is the composite action of 04-05: reusable steps with parameters, either in the same file or published in an orb.
  7. restore_cache with a list of keys: it tries the first; if it does not exist, the second as a prefix. It is the restore-keys of 04-02, and here it is completely explicit.
  8. save_cache in one job only. Cache keys in CircleCI are immutable: once a key is written, it is not overwritten. That forces you to version the key (npm-v2-) when you want to invalidate it, and it avoids the problem of several jobs overwriting each other.
  9. persist_to_workspace stores files for subsequent jobs of this same workflow. It is what stash was in Jenkins and what is done with artifacts in GitHub Actions.
  10. parallelism: 4 launches four identical containers of the job, with CIRCLE_NODE_INDEX and CIRCLE_NODE_TOTAL.
  11. The star of the show: circleci tests split --split-by=timings distributes the files across the four containers using the real timings from previous runs, not the number of files. Section 6.
  12. store_test_results is not cosmetic: it is what feeds the timing history the split above uses, as well as the failed and flaky test views. Without it, --split-by=timings degrades to splitting by name.
  13. store_artifacts uploads files downloadable from the interface, which is a different concept from the workspace (section 5).
  14. Federated identity via OIDC: CircleCI issues a token per job and AWS exchanges it for temporary credentials, exactly the design of 03-02. The trust condition in AWS is tied to the project and optionally to the context.
  15. Parameterised jobs invoked several times with a different name: a single deploy job serves staging and production, with different contexts. It is reuse without duplicating the definition.
  16. requires is the entire ordering mechanism. There are no stages.
  17. context supplies the secrets, and it is assigned per invocation in the workflow, not inside the job. That separation is one of CircleCI's best ideas: the same job runs with staging or production credentials depending on who invokes it.
  18. type: approval is a special job with no steps: a button. Its semantics are the gate of 03-01; who may press it is controlled by the context's and the project's restrictions.

  1. Explicit cache: keys, restore-keys and degradation

Compared with the "automatic" cache of setup-node in GitHub Actions or GitLab's declarative cache:, here everything is manual. You pay in verbosity and gain in control.

- restore_cache:
    keys:
      # 1 · exact match: same lockfile, same Node version, same OS
      - deps-v3-{{ arch }}-{{ checksum "package-lock.json" }}
      # 2 · fallback: any cache for this architecture, even if the lockfile differs
      - deps-v3-{{ arch }}-
      # 3 · last resort
      - deps-v3-

- run: npm ci --prefer-offline

- save_cache:
    key: deps-v3-{{ arch }}-{{ checksum "package-lock.json" }}
    paths: [ ~/.npm ]
    when: on_success                    # do not save a cache produced by a broken build

Three rules that avoid the usual failures:

  1. The key must include everything that invalidates the cache: the lockfile, the architecture and, if you have several runtime versions, the version. A Node 20 cache restored in a Node 22 job produces native module errors that cost hours to diagnose.
  2. The partial fallback is what makes the cache useful. Without it, any change to the lockfile forces a download of everything from scratch. With it, the previous cache is restored and npm ci only fetches what changed. It is the restore-keys mechanism of 04-02.
  3. The versioned prefix (v3) is the invalidate button. Because keys are immutable, the only way to force a clean cache when it becomes corrupted is to bump the number. Having it from the start saves you a bad afternoon.

And a security warning that applies to every tool: the cache is a write channel between branches. A contributor's PR that runs save_cache can poison a cache that a main build later restores. Cache only artifacts derived from dependencies verified by the lockfile, never built binaries that are subsequently run with privileges (04-03).

  1. Workspace, cache and artifacts: three different things

CircleCI is the tool that makes this distinction most explicit, and that is why it is the best place to pin it down.

Cache Workspace Artifacts
What for Speed Passing files between jobs of the same workflow Storing outputs for people or external systems
Scope Across workflows, across branches One workflow Persistent, downloadable from the interface
How save_cache / restore_cache persist_to_workspace / attach_workspace store_artifacts
Key Explicit, immutable Paths; accumulates per job Path and name
If it disappears Slower The pipeline fails The evidence is lost
Typical content ~/.npm, ~/.gradle dist/, compiled sources, digest.txt Coverage reports, Playwright screenshots, SBOM

The classic mistake: using the cache to pass dist/ between build and deploy. It works almost always and fails the day the cache is not there or when it brings another branch's, and then an old build gets deployed with nothing raising an alarm. A deployment with the wrong artifact is worse than a failure, because it goes undetected. The rule, the same as in 06-02: if losing it breaks the correctness of the result, it is not cache.

A practical detail about the workspace: it accumulates. If build persists dist/ and publish persists digest.txt, a later job that does attach_workspace sees both. And because every persist_to_workspace adds, it is best to persist the minimum: persisting the whole of node_modules is slow and almost never necessary if the cache is set up properly.

  1. Parallelisation and test splitting by historical timings

This is CircleCI's strongest technical argument and the most mature solution to the problem 02-04 raised and 04-04 left half-solved.

Naive sharding splits by number of files. With Reservalia's tests:

Container Files Real time
1 34 2 min 10 s
2 34 1 min 40 s
3 34 7 min 50 s ← carries the database integration tests
4 34 2 min 05 s

The job takes 7:50, not 3:30. You pay for four containers and use one. With --split-by=timings, CircleCI uses the per-file timings from previous runs — which it knows because store_test_results uploads them — and splits to equalise duration:

Container Files Real time
1 51 3 min 25 s
2 44 3 min 30 s
3 12 3 min 20 s
4 29 3 min 35 s
- run:
    command: |
      # glob → list of files; split → only the ones for this container
      FILES=$(circleci tests glob "apps/**/*.test.ts" | circleci tests split --split-by=timings)
      npm test -- --runTestsByPath $FILES --reporters=jest-junit
- store_test_results: { path: reports }      # essential: without this there is no history

Nuances worth knowing:

  • The first run has no data and splits by file name; from the second onwards, it improves. If you add many new tests at once, the split takes a run or two to rebalance.
  • There are other strategies: --split-by=filesize (useful with no history) and --timings-type=classname for languages where the unit is not the file.
  • It does not fix a slow test: it splits better, but a single five-minute test is still the floor of the job. 04-04 already warned that parallelising does not replace fixing.
  • Interaction with flaky tests: an unstable test fails in one container and brings the whole job down; the split does not change that. The quarantine of 02-04 is still necessary.

Compared with the other tools: in GitHub Actions and GitLab, timing-balanced splitting has to be built (storing timings as an artifact and writing the split yourself, or with third-party tools). Here it is one command. That is a difference of days of work, and it is real.

  1. Reuse: commands, parameters and orbs

Three levels, from the most local to the most shared:

# 1. Command: reusable steps within the project (≈ composite action)
commands:
  notify:
    parameters:
      channel: { type: string, default: "#reservalia-ci" }
      status:  { type: enum, enum: [ok, failure] }
    steps:
      - run:
          when: always
          command: ./scripts/notify.sh "<< parameters.channel >>" "<< parameters.status >>"

# 2. Parameterised job: the same job invoked with different data
jobs:
  deploy:
    parameters:
      environment: { type: enum, enum: [staging, production] }
      wait:        { type: integer, default: 60 }
    steps:
      - run: ./scripts/deploy.sh << parameters.environment >> << parameters.wait >>

# 3. Orb: versioned package, published and reusable across projects
orbs:
  reservalia: reservalia/platform@2.3.0
workflows:
  ci:
    jobs:
      - reservalia/build-publish:
          dockerfile: apps/api/Dockerfile
          repository: reservalia/api

An orb is a package with three kinds of content — commands, jobs and executors — published with semantic versioning. Publishing your own:

# src/@orb.yml of the reservalia/platform orb
version: 2.1
description: Common pieces of the Reservalia pipelines

commands:
  prepare-node:
    parameters:
      version: { type: string, default: "22.11" }
    steps:
      - checkout
      - restore_cache: { keys: ["npm-v2-{{ checksum \"package-lock.json\" }}", "npm-v2-"] }
      - run: npm ci --prefer-offline
# Publishing: first a development version, then the semantic promotion
circleci orb pack src/ > orb.yml
circleci orb validate orb.yml
circleci orb publish orb.yml reservalia/platform@dev:testing        # mutable, for testing
circleci orb publish promote reservalia/platform@dev:testing patch  # immutable: 2.3.1

This is exactly the idea of 04-05 — a central template repository — but with two design advantages: semantic versioning is mandatory and published versions are immutable, so the "somebody changed the library's main and broke everybody" problem we saw in Jenkins and in GitLab's includes cannot happen by accident. The trade-off is still the same: a third-party public orb is code that runs with your secrets, so the rules of 04-03 apply just the same — review what it does, pin to an exact version, prefer certified or in-house orbs for anything that touches credentials.

Orbs can be public (open catalogue, visible code) or private within your organisation, which is what you would use for reservalia/platform.

  1. Contexts, secrets and approvals

There are two places where secrets live, and the difference matters:

Project variables Context
Scope One project The whole organisation
How it is assigned Always, to every job in the project Per job invocation in the workflow
Access restriction Per project By security group: only certain teams
Typical use The project's own configuration Shared credentials and, above all, production ones
workflows:
  cd:
    jobs:
      - deploy:
          name: deploy-production
          context: [aws-production, notifications]    # only this job sees them
          requires: [approve-production]

The correct practice: production credentials live in a context restricted to a security group, and are attached only to the production deployment job. No test job has them, so a PR cannot exfiltrate them even if it modifies the config.yml — provided the context is restricted, which is what you have to verify. It is the same reasoning as the protected variables of 06-02 and the least privilege of 04-03, with a different mechanism.

On approvals: type: approval is an empty job that blocks the graph until somebody presses it. It is simpler than GitHub's Environments or GitLab's protected environments: there is no per-environment deployment history or rollback from the interface, because CircleCI does not model the environment as an object. If you want to know which version is in production, you build that yourself. It is the direct trade-off of not being an integrated platform.

  1. Resources, machine classes and the credit model

jobs:
  test:
    docker: [ { image: cimg/node:22.11 } ]
    resource_class: large       # more CPU and memory per minute, and more credits per minute

CircleCI bills in credits, and that is the part of the model you have to understand before signing. The idea: every minute of execution consumes credits, and how many depends on the resource class. A large class does not cost the same per minute as a small one; the macos executors and GPU machines are in another order of magnitude entirely (it is exactly the cost problem 05-02 ran into with macOS runners). Other features, such as docker_layer_caching, also consume. On top of that there is a per-active-user component.

I am not giving figures: they change, and they depend on the plan and the region. What does not change is how to reason about them:

  • The cost is minutes × class × concurrency. Moving from medium to large only pays off if the time drops by more than the multiplier. You measure that with one run of each; you do not estimate it.
  • Parallelisation multiplies the cost by the number of containers even though wall-clock time falls. Four containers for 3 minutes consume more than one for 8. You buy speed with money, which is the explicit decision 04-04 asked you to take knowingly.
  • A pipeline that runs twice per push — branch and PR — doubles the bill without providing new information. That is what filters are for.
  • A badly configured cache is paid for on every run of every branch.

And a warning that holds for all per-minute SaaS: the cost grows with the team and with activity, not with value. A team that triples its commits triples the bill. It pays to have consumption alerts and to review the most expensive jobs monthly, with the same discipline you apply to reviewing pipeline time.

  1. Debugging: SSH into build and local execution

Two tools that solve the "it fails in CI but not on my machine" problem, which is where most time is lost.

SSH into build: rerun a job with SSH enabled and connect to the machine while it runs.

# After clicking "Rerun job with SSH" in the interface:
ssh -p 54782 ubuntu@54.xx.xx.xx
# Once inside: the workspace is exactly as the job left it
cd ~/reservalia && npm test -- apps/api/bookings.test.ts
env | grep -i database

It is the fastest way to diagnose an environment-dependent failure. Two serious warnings: the session keeps the machine reserved and consuming credits until you close it or it expires; and the environment contains the job's secrets, so who may open an SSH session is a security decision, not a convenience one (04-03). In a job with production credentials, restrict that ability.

Local execution with the CLI, to iterate without spending remote runs:

circleci config validate                          # syntax errors before pushing
circleci config process .circleci/config.yml      # expands orbs and parameters: see the real YAML
circleci local execute --job quality              # runs a job in local Docker

circleci config process deserves attention: it shows the file after expanding orbs, commands and parameters, and it is the best way to understand what a third-party orb actually does before handing it your secrets. circleci local execute has limits — it does not support full workflows, workspaces between jobs or contexts — so it works for individual jobs, not for validating the whole pipeline. Even so, it is one of the module's best answers to the "how do I test the pipeline?" question of 04-05.

  1. Limits and trade-offs

With the same frankness applied to Jenkins and GitLab:

  • Coupling to a SaaS. The control plane belongs to CircleCI: if the service is unavailable, there is no CI, not even with your own runners. And there is a specific historical lesson: in 2023 CircleCI disclosed a security incident that forced customers to rotate every secret stored in the service. It is the same structural risk 06-04 will describe with Travis: entrusting secrets to a third party is a decision with consequences, and having a rehearsed mass-rotation procedure is not paranoia.
  • It is not a platform. There is no repository, no registry, no environments, no issues. It integrates with what you have, and everything GitLab gave away for free has to be assembled here.
  • A smaller ecosystem. The orb catalogue is markedly smaller than GitHub's actions catalogue. For the common things there is an orb; for the unusual, you write it.
  • Cost grows with the team, and expensive executors (macOS, GPU) can unbalance a bill fast.
  • Self-hosted runners: they exist, and they solve the "the job must run on my network or my hardware" case. But the control plane is still SaaS, so they do not resolve the strict compliance constraint that led to Jenkins in 06-01. It pays not to confuse "the job runs on my machine" with "the whole system is under my control".
  • Market. CircleCI's relative weight has declined since GitHub Actions became integrated into the place where almost everybody's code already lived. That matters for hiring, for finding answers and for the tool's longevity.

  1. When to choose CircleCI

Yes, when:

  • Pipeline speed is a measured and expensive problem, with a large, slow suite. Splitting by timings and the resource classes give real improvements without writing your own infrastructure.
  • You want a powerful CI without marrying the repository's platform, or you have projects on more than one Git provider.
  • You need macOS, ARM or GPU conveniently and without operating machines: the executor offering is broad (relevant to the mobile case of 05-02).
  • You value first-class diagnostic tooling — insights, SSH into build, local validation — and you have somebody who will use it.
  • You already have it and it works. Migrating costs, and 06-07 puts numbers on that calculation.

No, when:

  • Your code is on GitHub and you do not have a speed problem: GitHub Actions comes integrated and without a second permissions integration to maintain.
  • You need total control or strict compliance: a SaaS control plane does not allow it (that is Jenkins, 06-01).
  • You want an integrated platform: that is GitLab (06-02).
  • Budget is a hard constraint and the team is growing: the per-credit and per-user model scales with activity.

Common Mistakes and Tips

Not calling store_test_results. Without it, --split-by=timings has no history and the split degrades to alphabetical, which is precisely the imbalance you were trying to avoid. It is the mistake that most often turns a good idea into a disappointment.

Cache keys without architecture or runtime version. Restoring a cache from another architecture produces native module failures that are hard to read. Include {{ arch }} and the version.

No fallback in restore_cache. With a single exact key, any lockfile change forces a full download.

Using the cache to pass artifacts between jobs. That is what causes an old build to be deployed with nothing failing. That is what the workspace is for.

Not waiting for the secondary containers. PostgreSQL takes time to accept connections; without dockerize -wait you will get flakiness that depends on how loaded the machine is.

Moving up a resource_class without measuring. Doubling the class does not always reduce the time (a build limited by I/O or by a sequential test does not improve) but it does double consumption.

High parallelism by default. It multiplies the cost linearly. The optimum is found by measuring total time against credits, and it is usually lower than intuition suggests.

Leaving SSH sessions open. They reserve the machine and consume. Close them.

Unpinned orbs or orbs of unknown origin. They run with your secrets. Pin to an exact version, review with circleci config process and prefer certified or in-house orbs for anything that touches credentials.

A cross-cutting tip: even if you use commands and orbs, keep the real logic in scripts in the repository (./scripts/deploy.sh) and leave the YAML as the orchestrator. It is what makes exercise 3 of this lesson a matter of hours rather than weeks, and it is the portability lesson 06-07 will develop.

Exercises

Exercise 1. Reservalia's test job uses parallelism: 6 and takes 9 minutes. Looking at the containers: five finish between 1:30 and 2:10, and one takes 8:50. The config.yml shows circleci tests glob "apps/**/*.test.ts" | circleci tests split and there is no store_test_results anywhere. Diagnose, fix, and work out what happens to time and to consumption. Would it be worth lowering parallelism?

Exercise 2. Reservalia has six repositories on CircleCI and the preparation and publishing block repeated in all of them. Design the reservalia/platform orb: what it contains, how it is versioned, how it is tested before publishing, how it is rolled out to the six repositories and what policy applies to the third-party orbs they already use.

Exercise 3. Diego brings a spreadsheet: CircleCI consumption has risen by 240% in four months without the team growing. The data: 68% of consumption comes from the e2e job with resource_class: xlarge and parallelism: 8; two pipelines run per push (branch and PR); docker_layer_caching is enabled in four jobs; and there are 14 branches with scheduled nightly pipelines nobody looks at. Write the reduction plan, ordered by the ratio between saving and risk.

Solutions

Solution 1.

Diagnosis. circleci tests split without --split-by=timings splits by file name, and without store_test_results there is no history even if you asked for it. The slow container concentrates the tests that take time — probably the database integration ones. The job takes as long as the worst container: 8:50. You pay for six containers for almost nine minutes and use one.

Fix:

  test:
    executor: node-base
    parallelism: 4
    steps:
      - attach_workspace: { at: ~/reservalia }
      - run: dockerize -wait tcp://localhost:5432 -timeout 1m
      - run:
          command: |
            FILES=$(circleci tests glob "apps/**/*.test.ts" \
              | circleci tests split --split-by=timings)
            npm test -- --runTestsByPath $FILES --reporters=jest-junit
      - store_test_results: { path: reports }     # essential
      - store_artifacts:    { path: coverage }

Effect. Sum of real work ≈ 5×1:50 + 8:50 ≈ 18 min. Split across 4 containers, ~4:30 each plus start-up: the job goes from 8:50 to about 5 minutes. Consumption also drops in two ways: fewer containers (4 instead of 6) and fewer minutes per container.

Lower parallelism? Yes, and the reasoning matters more than the number. With 18 minutes of real work:

parallelism Approximate time Container·minutes
1 18:00 18
2 9:00 18
4 4:30 18 + start-ups
6 3:00 18 + more start-ups
12 1:30 18 + start-up dominates

Total work is almost constant, so the cost per run barely changes with parallelism — until container start-up stops being negligible against the work assigned. That is why the answer is "4 is fine and 6 was not the problem": the problem was the split, not the number. The practical rule: raise parallelism as long as each container still does considerably more work than it costs to start it, and fix the split before touching the number.

Solution 2.

Contents of the orb:

# src/@orb.yml
version: 2.1
description: Common CI/CD pieces for Reservalia

executors:
  node:
    parameters: { version: { type: string, default: "22.11" } }
    docker: [ { image: cimg/node:<< parameters.version >> } ]
    resource_class: medium

commands:
  prepare:
    parameters:
      cache-version: { type: string, default: "v2" }
    steps:
      - checkout
      - restore_cache:
          keys:
            - npm-<< parameters.cache-version >>-{{ arch }}-{{ checksum "package-lock.json" }}
            - npm-<< parameters.cache-version >>-{{ arch }}-
      - run: npm ci --prefer-offline --no-audit

jobs:
  build-publish:
    parameters:
      dockerfile: { type: string }
      repository: { type: string }
      publish:    { type: boolean, default: false }
    machine: { image: ubuntu-2404:current }
    steps:
      - prepare
      - run: ./scripts/build-image.sh << parameters.dockerfile >> << parameters.repository >> << parameters.publish >>

Note that the job calls a script in the repository rather than embedding twenty lines of shell in the YAML: that keeps the logic portable and makes the orb a thin orchestrator.

Versioning and publishing: semantic versioning is mandatory; breaking changes bump major. The flow is publish to @dev:<label> (mutable, for testing) and publish promote to a stable version (immutable). Repositories pin to the exact version @2.3.0, not to @2, so that no adoption is automatic.

Testing before publishing: the orb's own repository has a pipeline with circleci orb validate, circleci config process over a sample config.yml to check that it expands as expected, and an integration project (reservalia-orb-sandbox) that consumes @dev:testing and exercises every command on each commit. It is the canary of 04-05.

Rollout to the six repositories: progressive. 2.4.0 is published, adopted in one low-risk repository for a week, and only then is a PR opened in the other five. Never all six at once, and never by automatic adoption: the large blast-radius risk is the same as in Jenkins and GitLab.

Policy on third-party orbs: an allowlist reviewed by the platform team; pinned to exact versions; no third-party orb in jobs that carry production contexts except certified and reviewed ones; and circleci config process as a mandatory review step when adding a new one, to see what commands it actually runs. It is the dependency policy of 04-02 applied to the pipeline.

Solution 3. Ordered by saving divided by risk, which is the correct order for this kind of work:

# Action Estimated saving Risk Effort
1 filters so branch and PR pipelines do not both run ~50% of everything None 1 h
2 Turn off the 14 nightly pipelines nobody looks at Direct and measurable Low: if nobody looks, they give no signal 1 h
3 Fix the e2e split (timings) and lower parallelism from 8 to 4 High, over the 68% Low 0.5 day
4 Lower e2e from xlarge to large and measure Medium Low if measured before/after 0.5 day
5 Remove docker_layer_caching where there is already a registry cache Medium Low: compare the time with and without 0.5 day
6 Run the full e2e only on main and a subset on PRs High Medium: less signal before merging 2 days

Notes on judgement. Point 1 is the most profitable of the plan and the most invisible: it does not show up as slowness, only as a bill, and that is why it has been sitting there for months. Points 3 and 4 have to be done in this order and with measurement in between, because if you change them at once you cannot tell which caused what; and xlarge may be justified if e2e uses real browsers, so you check rather than assume. Point 6 is the only one that changes the quality contract: it reduces the signal before merging and can therefore raise the CFR, so it is decided with Marta, you write down which subset runs and why, and you watch the DORA metrics for a month. If the CFR rises above the current 3.8%, it is reverted.

And the methodological observation to give Diego: the 240% did not come from one change, it came from four small decisions nobody reviewed. The structural fix is not this list, it is putting a consumption alert and a monthly review of the most expensive jobs in place, with the same discipline used to review pipeline time in 04-04. Without that, in four months there will be another 240%.

Conclusion

CircleCI is the tool in the module that best answers one specific question: how to make a large pipeline fast without building the infrastructure yourself. Its three distinctive pieces are transferable learning even if you never use it: the explicit cache with keys and fallback forces you to understand what invalidates what; the sharp separation between workspace, cache and artifacts clears up a confusion that in other tools can be carried for years; and test splitting by historical timings is the best available solution to the sharding problem, and knowing it exists changes what you ask of the others.

What you pay: it is a SaaS and its control plane is not yours — with the precedent of the 2023 mass secret rotation as a reminder — it is not a platform and everything GitLab gave away you assemble here, the ecosystem is smaller, and the credit model grows with the team's activity, which demands a watch on spending that should not be left until the bill arrives.

The next lesson changes register. Travis CI invented a good part of what we take for granted today — the CI file in the repository, the version matrix, free CI for open source — and today it is, above all, something you will find inherited and a history lesson with a moral: about business models that change under your feet, about entrusting secrets to a third party, and about why it pays for your pipeline not to depend too heavily on the tool that runs it. We will look at its fixed-phase model, the Reservalia pipeline forced to fit inside it, and how to migrate a .travis.yml to whatever you use today.

CI/CD Course: Continuous Integration and Deployment

Module 1: Introduction to CI/CD

Module 2: Continuous Integration (CI)

Module 3: Continuous Deployment (CD)

Module 4: Advanced CI/CD Practices

Module 5: Implementing CI/CD in Real Projects

Module 6: Tools and Technologies

Module 7: Practical Exercises

Module 8: Additional Resources

© Copyright 2026. All rights reserved