Chapter 11Lesson 01~205 minutes

CI/CD YAML, Scripts, Images, Services, before_script, after_script, and Defaults: Concepts, Architecture, and Mental Model

Understand GitLab CI/CD job configuration as executable infrastructure: YAML job keys, defaults, lifecycle scripts, image and service runtime boundaries, shell behavior, working directories, and executor-dependent semantics.

CI YAMLJob lifecycledefaultImagesServicesExecutor boundary

Learning objectives

  • Explain how GitLab turns YAML job keys into runner-side execution rather than treating YAML as a shell script.
  • Describe default inheritance precisely, including why a job-level keyword replaces rather than deep-merges the same default keyword.
  • Explain the before_script → script → after_script lifecycle and the separate-shell semantics of after_script.
  • Distinguish a job image from service sidecars and explain executor-dependent behavior, network aliases, and readiness.
  • Identify reproducibility risks created by mutable image tags, shell assumptions, working-directory changes, and hidden inherited configuration.
Availability baseline (verified 2026-08-21). The CI/CD YAML keywords taught here—default, image, services, before_script, script, and after_script—are part of core GitLab CI/CD and are available on Free, Premium, and Ultimate across GitLab.com, Self-Managed, and Dedicated. Runtime behavior still depends on the runner executor. In particular, container image/services semantics require a compatible container-capable executor; a Shell executor runs commands directly on the runner host and has materially different isolation and dependency assumptions. Hosted-compute quotas and registry availability can change, so all mandatory learning also has a CI Lint/log-fixture or local-container fallback.

1. Why maintainable CI YAML is harder than valid YAML

Chapter 10 established when a pipeline and job exist, which commit they represent, and which runner executes them. Chapter 11 moves one layer deeper: what the runner actually constructs and executes for a job. A file can be syntactically valid YAML and still be fragile infrastructure because an image tag moves, a service is not ready, a default silently changes multiple jobs, or a cleanup command runs in a different shell than the main script.

The practical objective is not to memorize keywords. It is to make each job explainable as a runtime contract: configuration source → inherited values → executor → image/environment → service dependencies → lifecycle commands → exit status → evidence.

2. Job configuration becomes a runtime contract

Configuration and execution layers
flowchart TD
  A[Committed .gitlab-ci.yml] --> B[GitLab parses / resolves includes]
  B --> C[default values copied into jobs]
  C --> D[Job-specific keys override defaults]
  D --> E[Runner selects executor]
  E --> F[Job environment / image]
  E --> G[Service sidecars if supported]
  F --> H[before_script + script]
  G --> H
  H --> I[after_script in fresh shell]
  I --> J[Artifacts/cache upload + final evidence]

Every arrow has a boundary. GitLab resolves configuration before a runner executes anything. Defaults are copied into jobs that lack a same-named keyword. The runner/executor decides how image, services, filesystem, shell, and networking are realized. before_script and script execute together in the main shell; after_script runs afterward in a separate shell context.

Layer Question to ask Evidence
YAML/config What keys survive include/default/inheritance resolution? Pipeline Editor / CI Lint merged configuration.
Runner Which runner and executor claimed the job? Job metadata and runner details.
Runtime image Which filesystem/tools/entrypoint were used? Runner log, CI_JOB_IMAGE where available, recorded image reference/digest.
Service Which sidecar is reachable by which alias, and when is it ready? Service log/readiness probe and connection result.
Shell Which shell interprets quoting, variables, pipelines, and exit codes? Executor/shell metadata plus controlled commands.
Lifecycle What runs before, during, and after the main script? Ordered job log and exit status.

3. Global keywords, jobs, and the default keyword

A GitLab CI file contains global configuration and jobs. default is a global keyword that supplies selected job-key values—such as image, services, before_script, after_script, retry, or tags—to jobs that do not define those keys themselves.

The key rule is copy, not deep merge. If a job defines its own before_script, it replaces the default before_script; GitLab does not append the arrays automatically. The same principle applies to other supported default job keywords.

default:
  image: alpine:3.22
  before_script:
    - echo "default setup"

uses_default:
  script:
    - echo "inherits image and before_script"

overrides_before:
  before_script:
    - echo "job-specific setup; default before_script is replaced"
  script:
    - echo "still inherits the default image"
Top-level global definitions of image, services, before_script, and after_script outside default are deprecated. Current examples should use default: or explicit job-level keys.

4. before_script, script, and after_script are not symmetric

before_script prepares the main job environment; GitLab Runner concatenates it with script so they execute in the same main shell context. Exports and shell state can therefore carry from before_script into script.

after_script is different. It executes in a new shell, its working directory is reset to the runner's default project checkout directory, and shell-local exports or aliases created earlier are gone. Files written inside the project working tree remain files, so they can still be inspected or included in artifacts. An after_script failure does not change an otherwise successful job's exit code.

lifecycle_probe:
  before_script:
    - export SESSION_ONLY="from-before"
    - printf 'file_state=from-before
' > lifecycle.txt
  script:
    - test "$SESSION_ONLY" = "from-before"
    - printf 'script_pwd=%s
' "$PWD"
    - printf 'file_state=from-script
' >> lifecycle.txt
  after_script:
    - printf 'after_pwd=%s
' "$PWD"
    - test -z "${SESSION_ONLY:-}"   # fresh shell: exported value is gone
    - cat lifecycle.txt             # file persists in project workspace

5. An image is a runtime dependency and a supply-chain decision

With a compatible executor such as Docker, image selects the container image used for the job. GitLab accepts an unqualified name, a tag, or a digest reference. A tag such as alpine:3.22 is more reproducible than alpine:latest, but a tag can still move. A digest identifies exact registry content and is the strongest runtime identity when your registry/workflow supports it.

The image also brings assumptions: available shell, package manager, certificates, entrypoint, user, filesystem layout, architecture, and tools. GitLab Runner starts the image according to executor rules; a Dockerfile WORKDIR does not mean CI scripts automatically execute there. For Docker jobs, scripts normally run in GitLab's build directory for the checked-out project.

Reference Reproducibility Operational note
image: alpine Weak Implicit latest-style tag; avoid for governed pipelines.
image: alpine:3.22 Better Version tag is explicit but can still be republished/moved upstream.
image: registry.example.invalid/team/ci@sha256:<digest> Strong identity Digest fixture shows the shape; verify a real digest before use.

6. Services are sidecars, not packages installed into the job image

A service is an additional container (for compatible executors) made reachable to the job over a job network. A database service does not place psql into the job container and an HTTP service does not become localhost automatically. The job connects to the service by its configured alias or derived hostname.

service_probe:
  image: alpine:3.22
  services:
    - name: nginx:1.28-alpine
      alias: web
  script:
    - |
      for n in 1 2 3 4 5; do
        if wget -qO- http://web/ >/dev/null; then
          echo "service ready"
          exit 0
        fi
        sleep 1
      done
      echo "service not ready" >&2
      exit 1
Readiness is part of the contract. “Container started” and “application ready to accept requests” are different states. A bounded health/readiness loop is more reliable than assuming startup is instantaneous.

7. Executor differences are semantic, not cosmetic

A Docker executor can construct isolated job and service containers and honor container-specific image configuration. A Shell executor executes on the runner host, depends on host-installed tools, and offers much less isolation. The same script may therefore behave differently because the operating system, shell, path, user privileges, filesystem, or installed tools differ.

Do not write “GitLab uses Bash” as a universal rule. GitLab Runner can generate scripts for different shells/platforms. If your commands depend on Bash syntax, PowerShell semantics, GNU utilities, or Linux paths, state that requirement and constrain the runner appropriately.

8. Read-only inspection before editing YAML

  • Pipeline Editor / CI Lint: inspect the current merged/validated configuration.
  • Job page: record job SHA, runner, and executor clues from the log before changing configuration.
  • Current YAML: find inherited default and include values before adding a job override.
  • Image reference: record tag/digest and registry. Do not assume the same tag means the same bytes forever.
  • Service: record alias, port/protocol, and readiness condition; never paste production database credentials into a teaching service.
  • Shell: identify whether the job is Linux shell, PowerShell, or another supported environment before using shell-specific syntax.

9. DevOps connection: YAML is executable infrastructure

A pipeline configuration controls executable dependencies and trust boundaries. A mutable image can change behavior without a repository diff; a broad default can alter dozens of jobs; a service can pull unreviewed software; a Shell runner can expose host state; and ambiguous quoting can turn data into shell syntax. Treat YAML review with the same rigor as application and infrastructure code: pin, inspect, test, scope, and preserve evidence.

Knowledge check

If a job defines its own before_script, are the default commands appended automatically?

Why can after_script read a file created during script but not an exported shell variable?

Does a successful service-container startup guarantee the application inside it is ready?

Why is image:latest weak evidence of what executed?

Why might the same script behave differently on Shell and Docker executors?

Summary

Maintainable GitLab CI YAML is a runtime contract: resolve configuration and defaults, know which executor implements it, identify the job image and service dependencies, understand shell lifecycle boundaries, verify working directory and exit semantics, and treat external images as supply-chain inputs. The next lesson proves each concept with observable logs rather than assumptions.

Official references

Next lesson

Observe defaults, lifecycle, image, and service behavior

Lesson 2 runs a small reproducible pipeline, validates it before commit, captures execution order, demonstrates after_script isolation, records image/runtime evidence, and uses a harmless service with explicit readiness.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.