DRY GitLab Pipelines With default:, before_script and after_script
Stop repeating the same setup in every job. GitLab's default: keyword plus before_script and after_script give you clean, DRY pipeline-wide defaults — here's how to use them well.
- #gitlab-cicd
- #ai
- #before-script
- #default
- #dry
Open a neglected .gitlab-ci.yml and you’ll usually find the same five setup lines copy-pasted into every job: the same image, the same auth, the same cache restore, the same cleanup. It works, but it rots — change one and you have to change all of them, and you’ll miss one. GitLab’s default: keyword, together with before_script and after_script, lets you define that shared behavior once. Used well it makes pipelines dramatically cleaner; used carelessly it hides surprising behavior. Here’s the line I walk.
What default: actually sets
The default: keyword defines values that every job inherits unless it overrides them. The commonly useful ones are image, before_script, after_script, cache, tags, retry, and interruptible:
default:
image: node:22-alpine
before_script:
- npm ci --cache .npm --prefer-offline
after_script:
- echo "Job ${CI_JOB_NAME} finished with status ${CI_JOB_STATUS}"
retry:
max: 2
when: runner_system_failure
Now every job uses that image and runs npm ci first without restating it. A job that needs a different image just sets its own image:, which overrides the default for that job only.
before_script runs in the same shell as script
A crucial detail people miss: before_script is effectively prepended to script and runs in the same shell context. So an environment variable or working-directory change you make in before_script carries into script. That’s why it’s the right place for auth setup and dependency installation.
deploy:
before_script:
- export KUBECONFIG="$(pwd)/kubeconfig"
- echo "$KUBE_CREDS" > "$KUBECONFIG"
script:
- kubectl apply -f manifests/ # KUBECONFIG is set
Because they share a shell, a failing before_script line fails the job before script ever runs — which is usually what you want for setup that’s a prerequisite.
after_script is different — and that trips people up
after_script runs in a separate shell and runs even if the job failed (or was cancelled or timed out). That makes it perfect for cleanup and diagnostics, but it means it does not inherit shell state from before_script/script — variables you exported earlier aren’t there. And because it always runs, don’t put anything in after_script whose failure should fail the job; its exit status doesn’t affect the job result by default.
default:
after_script:
- ./collect-logs.sh || true # diagnostics, never block on it
- ./teardown-temp-resources.sh || true
The || true is deliberate — cleanup that errors shouldn’t mask the real outcome. Treat after_script as best-effort teardown, not as part of the job’s logic.
Override, don’t duplicate
When one job needs extra setup, override the default and call back to it rather than copy-pasting. A clean way is to keep the shared steps in a YAML anchor or !reference and compose:
.setup: &setup
- npm ci --prefer-offline
default:
before_script: *setup
migrate:
before_script:
- *setup
- export DATABASE_URL="$STAGING_DB"
script:
- npm run migrate
This keeps the shared install in exactly one place while letting migrate add to it. The alternative — restating the install in migrate — is the duplication you were trying to escape.
Let AI refactor the duplication — carefully
Hoisting repeated lines into default: is exactly the kind of mechanical refactor AI does well, but it can change behavior if it hoists something that wasn’t truly universal. I scope the request tightly:
Prompt: “Refactor this .gitlab-ci.yml to remove duplication. Move only the setup that appears in EVERY job into default:before_script, and only the cleanup that appears in every job into default:after_script. For any line that appears in most-but-not-all jobs, do NOT hoist it — instead show it as a !reference snippet the relevant jobs opt into. List exactly which jobs change behavior, if any.”
The behavior-change list is the safety net:
Output (excerpt): “Hoisted to default:before_script:
npm ci(present in all 8 jobs). Hoisted to default:after_script: log collection (all 8). NOT hoisted:export KUBECONFIG(only 2 deploy jobs) — kept as a.kube-setup!reference those two jobs include. Behavior change: thelintjob previously had no after_script and now runs log collection — confirm that’s acceptable.”
That last line is exactly the kind of thing to catch before merging: a job that gains an after_script it never had. AI drafts the refactor; you verify the behavior-change list and run the pipeline before trusting it. For reusable patterns, see the default keyword global job config prompt, the extends and !reference prompt, and the broader GitLab CI/CD category.
The bottom line
default: plus before_script and after_script are the cleanest way to kill copy-paste in a GitLab pipeline — define the shared image, setup, cleanup, and retry once and let jobs inherit them. Remember that before_script shares a shell with script (so exports carry through) while after_script runs in a separate shell and always runs (so keep it best-effort with || true). Override defaults per job by composing with !reference rather than duplicating, and when you let AI do the refactor, make it hoist only the truly universal steps and report every behavior change. Clean defaults, no surprises.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.