← back to blog
CI/CD passing · 19 September 2026 · 6 min read

Building the Pipeline: GitLab, Two Jobs, Zero Dollars

CI/CDGitLabAWSIAM

The last post was about deciding: what “free” actually means across a handful of CI options, and why a persistent server is the real cost driver, not the vendor. This one is about what got built once the decision landed on GitLab: two jobs, a security stage in front of both, and a bill that stays at zero.

Why GitLab, specifically

GitLab CI’s free tier (400 minutes a month, private repos included) was already on the shortlist. What tipped it into “move the whole repo there” instead of just mirroring for CI was avoiding a second system to keep in sync. A mirror means two remotes, two places a push can silently diverge, and two dashboards to check when something’s wrong. A full move means one remote, one CI system, one place to look. For a repo this small, the migration itself (GitLab’s GitHub import preserves full history) took less time than setting up a mirror would have.

What it actually costs

Nothing, in the range this site operates in. The pipeline runs two or three jobs per push, each finishing in low single-digit minutes, nowhere near the 400-minute monthly allowance. The one AWS-side line item is the IAM user the pipeline authenticates as, and IAM users themselves aren’t billed; what it does (S3 syncs, CloudFront invalidations, occasional Terraform applies) is the same usage this project’s Terraform already accounts for, just triggered by a push instead of a terminal command.

How it’s actually wired

Three stages, in order: security, terraform, deploy. The last two are gated by which paths changed: a commit that only touches a blog post never runs terraform apply, and a Terraform-only commit never rebuilds the site. That’s a rules: changes: block per job, not anything clever, but it’s the difference between “the pipeline runs the right thing” and “the pipeline runs everything, always, and you just trust it skipped what didn’t matter.”

stages: [security, terraform, deploy]

terraform-apply:
  stage: terraform
  image: { name: hashicorp/terraform:1.4.6, entrypoint: [""] }
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      changes: ["terraform/*.tf", "terraform/lambda/**/*"]
  script:
    - cd terraform
    - terraform init -input=false
    - terraform apply -auto-approve -input=false

deploy-content:
  stage: deploy
  image: node:22-alpine
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      changes: ["src/**/*", "package.json", "package-lock.json"]
  script:
    - npm ci && npm run build
    - aws s3 sync dist/ "s3://${SITE_BUCKET_NAME}/" --delete
    - aws cloudfront create-invalidation --distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" --paths "/*"

Auth is a stored IAM access key, not OIDC, a deliberate choice after an earlier GitHub Actions attempt on this same project burned real time on two separate OIDC bugs (a stale certificate thumbprint, then a claim format GitHub had quietly changed). GitLab’s OIDC mechanics are different and neither bug would necessarily recur, but “stored key, correctly scoped” was the faster path to something working, and scope is the part that actually matters:

# excerpt: the CI user's Lambda-related permissions, not its full policy
statement {
  sid       = "LambdaProjectFunctions"
  actions   = ["lambda:*"]
  resources = ["arn:aws:lambda:eu-west-2:ACCOUNT_ID:function:PROJECT_PREFIX-*"]
}

Every statement in that policy is scoped to this project’s own resources by name prefix or exact ARN, with one deliberate exception: CloudFront, where AWS’s own IAM model doesn’t support resource-level scoping for several of the actions Terraform needs. The one thing the policy explicitly never grants, anywhere: any iam:*User* action, and no unscoped iam:PassRole. That means the pipeline’s own identity can manage the one Lambda execution role this project needs, but it cannot touch its own permissions, so even a fully malicious change merged to the main branch has no path to grant itself anything broader than what’s already listed in a policy document sitting in the repo. Even iam:PassRole, which the Lambda role does need, is pinned to one role and one service:

statement {
  sid       = "PassLambdaRoleToLambdaOnly"
  actions   = ["iam:PassRole"]
  resources = ["arn:aws:iam::ACCOUNT_ID:role/PROJECT_PREFIX-contact-lambda"]
  condition {
    test     = "StringEquals"
    variable = "iam:PassedToService"
    values   = ["lambda.amazonaws.com"]
  }
}

The security stage

Three checks run before either the Terraform or deploy job gets a chance to touch anything. Two are GitLab templates with one override each, and the third is a plain npm audit:

include:
  - template: Security/SAST.gitlab-ci.yml
  - template: Security/Secret-Detection.gitlab-ci.yml

secret_detection:
  stage: security
  allow_failure: false      # a leaked credential stops the pipeline

npm-audit:
  stage: security
  image: node:22-alpine
  allow_failure: true       # a known, accepted finding shouldn't block every deploy
  script:
    - npm audit --audit-level=high
  • Secret detection, set to actually fail the pipeline. A leaked credential is the one class of finding where “block first, argue about false positives later” is obviously correct.
  • Static analysis (SAST), left advisory. Static analysis on a small TypeScript codebase has a real false-positive rate, and blocking every deploy on a linter having opinions isn’t a security improvement, it’s a way to start ignoring the tool.
  • A dependency audit, also advisory, and advisory for a specific, named reason: this project already has one known, already-triaged dependency finding (a framework CVE, assessed low-risk because the vulnerable code path never runs in a static build) that would otherwise fail this job on literally every push. Blocking on a risk you’ve already decided to accept isn’t caution, it’s noise that trains you to stop reading the output.

That last distinction is the actual lesson: “block on this” and “surface this” are different decisions, and conflating them either creates alert fatigue (blocking on everything) or blind spots (blocking on nothing). Deciding per check, with a written reason, is what makes the difference legible six months later instead of just being an artifact of whatever felt right the day it was configured.

The other habit worth naming

None of this (the path-scoped triggers, the least-privilege IAM policy, the per-check block-or-warn call) is exotic. It’s the same short list every time: give automation the smallest identity that can do its job, decide deliberately what’s allowed to fail loudly versus quietly, and write down why a risk was accepted instead of just… accepting it and moving on. The interesting part of building this pipeline wasn’t picking GitLab over the alternatives. It was that most of the actual engineering happened after the vendor was already chosen.

The pipeline can ship. Next up: how to tell whether what it shipped is actually working: golden signals, a couple of real region-quirk bugs, and logs that expire on purpose.