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.
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.
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
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"
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
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
defaultandincludevalues 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?
No. The job-level keyword replaces the default value for that keyword; defaults are copied only when the job does not already define it.
Why can after_script read a file created during script but not an exported shell variable?
The file persists in the job workspace, but after_script runs in a new shell so earlier shell-local exports and aliases are not preserved.
Does a successful service-container startup guarantee the application inside it is ready?
No. Process/container startup and application readiness are different states; probe readiness with a bounded check.
Why is image:latest weak evidence of what executed?
The tag is mutable, so the same configuration can pull different content later without a repository change.
Why might the same script behave differently on Shell and Docker executors?
They provide different operating systems, shells, filesystem/isolation models, installed tools, users, and image/service capabilities.
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
- GitLab Docs — CI/CD YAML syntax reference
- GitLab Docs — Scripts and job logs
- GitLab Docs — Deprecated CI/CD keywords
- GitLab Docs — Use CI/CD configuration from other files
- GitLab Docs — Run CI/CD jobs in Docker containers
- GitLab Docs — Services
- GitLab Docs — Docker executor
- GitLab Docs — Shell executor
- GitLab Docs — Runner executors
- GitLab Docs — Pipeline editor
- GitLab Docs — CI Lint
- GitLab Docs — Predefined CI/CD variables
- GitLab Docs — Runner security
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.