GitLab gives you the strongest parent/child pipeline primitives of any major CI platform. Most HIPAA teams using GitLab still throw the advantage away.

A healthcare platform team I worked with on GCP had a 1,200-line .gitlab-ci.yml at the root of their monorepo. It shipped. Tests ran. Containers built. The platform was stable across roughly 32 production VM-backed services that mixed Tomcat, Node.js, and Ubuntu in ways nobody had inventoried. They had a 3PAO assessment scheduled in five weeks. The compliance officer wanted to know how the pipeline answered the Security Rule. The pipeline file was 1,200 lines and growing.

The pipeline could not, in any useful sense, answer the Security Rule. Production approval gates, scanner evaluations, evidence emission, deploy authorization, and per-service build logic all lived in the same file. Every YAML change risked dropping a compliance gate that nobody noticed was load-bearing. The auditor's question, "show me that production deploys are gated", required reading the entire file to answer. We rebuilt the pipeline before the assessment. The change that mattered most wasn't a new scanner or a tightened IAM role. It was the parent/child split.

This post is the GitLab-specific deep dive on that split. What moves to the parent, what stays in the child, how the artifact contract between them works, and how the pattern handles a polyrepo with five separate repos for backend, frontend, infrastructure, networking, and security. Argo CD comes in briefly at the deploy stage; the depth on that lives in the upcoming Kubernetes platform engineering posts.

If you've read the broader HIPAA CI/CD implementation guide, this is the GitLab-specific implementation of the parent/child architecture introduced there. The cloud examples here cover GCP because that is where this engagement lived, but the pattern ports directly to AWS with EKS plus IRSA in place of GKE plus Workload Identity.

Section 01Why GitLab is the strongest HIPAA CI primitive

Three GitLab features make the parent/child split nearly free. Used together, they're the cleanest primitives any major CI platform offers for HIPAA compliance work.

The trigger: keyword. A job in the parent pipeline triggers a downstream pipeline (the child), waits for it to complete, and consumes its artifacts. The parent does not need to know how the child builds its service. The child does not need to know which compliance gates the parent enforces. The interface is the artifact bundle.

Multi-project triggers. The same trigger: primitive crosses repository boundaries with trigger: project:. A parent pipeline in a compliance-platform repo can fan out to children in the backend, frontend, infrastructure, networking, and security repos. The compliance team owns the parent; service teams own their respective children. Code review, branch protection, and access controls are scoped per repo.

Tag-based runner targeting. Self-hosted runners register with one or more tags. The pipeline's tags: array determines which runner picks up the job. A job that requires the hipaa-prod-runner tag is only ever picked up by a runner with that tag registered. Runner registration is itself controlled by GitLab admins. Combined with scoped IAM per runner, the runner tag becomes a structural compliance control, not a procedural one.

None of these is exotic. All three are built into GitLab CE and have been stable for years. The three architectural decisions at the pillar page compose them into the audit-ready pattern this post implements.


Section 02What moves to the parent pipeline

The parent pipeline owns environment-level concerns. It changes rarely. The compliance team approves every merge to the repo that holds it. Every job in the parent is tied to a specific Security Rule control.

Five responsibilities sit in the parent:

Everything outside that list belongs in a child. Unit tests are not the parent's job. Container builds are not the parent's job. Lint checks are not the parent's job. The parent owns the gates; the children own the code.

# compliance-platform/.gitlab-ci.yml
# Parent pipeline. Owns gates, evidence, deploy authorization.
# Children live in the five service repos and are triggered below.

stages:
  - authorize
  - trigger-children
  - aggregate-evidence
  - policy-gate
  - deploy

variables:
  HIPAA_ENVIRONMENT: $CI_COMMIT_BRANCH
  EVIDENCE_BUCKET: "gs://hipaa-evidence-prod"

# Stage 1: identity verification before anything else runs
authorize:
  stage: authorize
  image: google/cloud-sdk:slim
  script:
    - ./scripts/verify-identity.sh "$GITLAB_USER_LOGIN" "$HIPAA_ENVIRONMENT"
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

# Stage 2: fan out to the five service-repo children in parallel
trigger-backend:
  stage: trigger-children
  trigger:
    project: hipaa-app-org/backend
    branch: main
    strategy: depend

trigger-frontend:
  stage: trigger-children
  trigger:
    project: hipaa-app-org/frontend
    branch: main
    strategy: depend

trigger-infra:
  stage: trigger-children
  trigger:
    project: hipaa-app-org/infrastructure
    branch: main
    strategy: depend

trigger-networking:
  stage: trigger-children
  trigger:
    project: hipaa-app-org/networking
    branch: main
    strategy: depend

trigger-security:
  stage: trigger-children
  trigger:
    project: hipaa-app-org/security
    branch: main
    strategy: depend

# Stage 3: pull evidence from every child into one bundle
aggregate-evidence:
  stage: aggregate-evidence
  image: google/cloud-sdk:slim
  script:
    - ./scripts/collect-child-evidence.sh "$CI_PIPELINE_ID"
    - gsutil cp evidence-bundle.json
        "$EVIDENCE_BUCKET/parent-$CI_PIPELINE_ID/bundle.json"
  needs:
    - trigger-backend
    - trigger-frontend
    - trigger-infra
    - trigger-networking
    - trigger-security

# Stage 4: OPA policy decides. No deploys past this without allow.
policy-gate:
  stage: policy-gate
  image: openpolicyagent/opa:0.62
  script:
    - opa eval -d policies/ -i evidence-bundle.json
        "data.deploy.hipaa.allow" --format=raw | grep -q true
  needs: ["aggregate-evidence"]

# Stage 5: human approval triggers the prod-only runner
deploy-production:
  stage: deploy
  tags: ["hipaa-prod-runner"]
  environment: production
  when: manual
  script:
    - ./scripts/deploy-signed.sh
  needs: ["policy-gate"]

That's the entire parent. Roughly 60 lines, every job tied to a Security Rule control, every stage doing one thing. The compliance team can read this file in two minutes and tell the auditor exactly which job satisfies which control. When something changes, the change is small and reviewable.

Section 03What stays in the child pipeline

The child pipeline owns the service. Every service repo has its own .gitlab-ci.yml. The service team owns the file. They can change it freely as long as the artifact contract back to the parent stays intact.

The child's responsibilities:

The child does not deploy. The child does not approve. The child does not decide whether a scan result is acceptable. Those are parent concerns. Keeping the boundary clean is what lets each child evolve independently.

# backend/.gitlab-ci.yml
# Child pipeline. Service team owns this file.
# Triggered by the parent in compliance-platform.

stages:
  - build
  - test
  - scan
  - sign
  - emit

variables:
  IMAGE: "us-east1-docker.pkg.dev/hipaa-app-prod/backend/backend"

build:
  stage: build
  tags: ["hipaa-build-runner"]
  script:
    - docker buildx build -t $IMAGE:$CI_COMMIT_SHA --output type=docker .
    - docker push $IMAGE:$CI_COMMIT_SHA
  artifacts:
    reports:
      dotenv: build.env  # exposes IMAGE_DIGEST to downstream jobs

test:
  stage: test
  tags: ["hipaa-build-runner"]
  script:
    - go test -race -coverprofile=coverage.out ./...
  coverage: '/^total:\s+\(statements\)\s+(\d+\.\d+)%/'

scan-container:
  stage: scan
  tags: ["hipaa-build-runner"]
  image: aquasec/trivy:latest
  script:
    - trivy image --severity HIGH,CRITICAL --format json
        --output trivy.json $IMAGE@$IMAGE_DIGEST
  artifacts:
    paths: [trivy.json]
    reports:
      container_scanning: trivy.json

scan-sast:
  stage: scan
  tags: ["hipaa-build-runner"]
  image: returntocorp/semgrep:latest
  script:
    - semgrep ci --config=p/hipaa --json --output=semgrep.json
  artifacts:
    paths: [semgrep.json]
    reports:
      sast: semgrep.json

sign:
  stage: sign
  tags: ["hipaa-build-runner"]
  script:
    - cosign sign --key gcpkms://projects/hipaa-app-prod/locations/us-east1/keyRings/hipaa/cryptoKeys/signing
        $IMAGE@$IMAGE_DIGEST
    - cosign verify --key gcpkms://...signing $IMAGE@$IMAGE_DIGEST > signature.json
  artifacts:
    paths: [signature.json]

emit-evidence:
  stage: emit
  tags: ["hipaa-build-runner"]
  script:
    - ./scripts/bundle-evidence.sh
        trivy.json semgrep.json signature.json
        > child-evidence.json
  artifacts:
    paths: [child-evidence.json]
    expire_in: 30 days  # parent collects within minutes

Notice what's not here. No deploy job. No approval gate. No reference to a production environment. The child does not know it is part of a HIPAA pipeline; it produces evidence and exposes it as artifacts. The parent does the rest.

Section 04The artifact contract between parent and child

The boundary between parent and child is the artifact contract. The parent triggers the child with strategy: depend; the parent's job stays in running state until the child finishes and exposes its artifacts. The parent then collects those artifacts and aggregates them.

Two GitLab artifact mechanisms carry the contract:

The parent's collect-child-evidence.sh script walks the API: list child pipelines triggered by this parent, list each child's jobs, fetch the artifact archives, extract the structured reports, and write the combined JSON bundle to the evidence bucket.

# scripts/collect-child-evidence.sh
# Walks the GitLab API to assemble the evidence bundle from
# every child pipeline triggered by this parent run.
#
# Output: evidence-bundle.json with one entry per child + scan
# Read by: the OPA policy gate in the next stage

set -euo pipefail

PARENT_ID="$1"
API="https://gitlab.com/api/v4"
TOKEN="$EVIDENCE_READ_TOKEN"

# Find every downstream pipeline this parent triggered
CHILD_IDS=$(curl --silent --header "PRIVATE-TOKEN: $TOKEN" \
  "$API/projects/$CI_PROJECT_ID/pipelines/$PARENT_ID/bridges" |
  jq -r '.[].downstream_pipeline.id')

bundle="{}"

for CHILD in $CHILD_IDS; do
  # Read the child's pipeline metadata
  meta=$(curl --silent --header "PRIVATE-TOKEN: $TOKEN" \
    "$API/projects/$CI_PROJECT_ID/pipelines/$CHILD")
  project=$(echo "$meta" | jq -r '.project_id')

  # Pull the child-evidence artifact produced by the emit job
  curl --silent --header "PRIVATE-TOKEN: $TOKEN" \
    "$API/projects/$project/jobs/artifacts/main/raw/child-evidence.json?job=emit-evidence" \
    -o "child-$CHILD.json"

  # Merge into the running bundle keyed by service name
  service=$(jq -r '.service' "child-$CHILD.json")
  bundle=$(echo "$bundle" | jq \
    --slurpfile child "child-$CHILD.json" \
    --arg svc "$service" \
    '.[$svc] = $child[0]')
done

# Add parent-level provenance (who triggered, when, against which tag)
bundle=$(echo "$bundle" | jq \
  --arg pipeline "$PARENT_ID" \
  --arg actor "$GITLAB_USER_LOGIN" \
  --arg env "$HIPAA_ENVIRONMENT" \
  --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  '. + {parent_pipeline_id: $pipeline, actor: $actor, environment: $env, timestamp: $ts}')

echo "$bundle" > evidence-bundle.json

The script is intentionally readable. Forty lines an auditor can walk in five minutes. Every API call uses a scoped token that can only read pipeline metadata and job artifacts; the parent cannot modify anything in the child repos. The output bundle is the input to OPA, and it's also what gets written to the evidence bucket with Object Lock. The chain of custody is queryable months later: parent pipeline ID joins to actor, joins to artifact digest, joins to scanner results, joins to deploy outcome.

Section 05Multi-project pipelines for a five-repo polyrepo

The team this pattern came from split their code across five repos: backend, frontend, infrastructure (Terraform), networking (VPC / firewall as code), and security (IAM, KMS, policy bundles). The split matched their team boundaries. The compliance constraint was that every deploy to production had to evaluate evidence from every repo, in the same run, before the gate opened.

Multi-project pipelines handle this cleanly. The parent in compliance-platform triggers a child in each of the five repos via trigger: project:. With strategy: depend, the parent waits for every child to complete before moving to evidence aggregation. The five children run in parallel; the slowest determines the total wait.

Three behaviors of multi-project triggers matter for HIPAA:

# compliance-platform/.gitlab-ci.yml (excerpt)
# Multi-project trigger with strategy: depend.
# Each child runs in parallel; parent waits on all five.

.child-trigger-template: &child-trigger
  stage: trigger-children
  strategy: depend
  variables:
    PARENT_PIPELINE_ID: $CI_PIPELINE_ID
    PARENT_ACTOR: $GITLAB_USER_LOGIN

trigger-backend:
  trigger:
    project: hipaa-app-org/backend
    branch: main
  <<: *child-trigger

trigger-frontend:
  trigger:
    project: hipaa-app-org/frontend
    branch: main
  <<: *child-trigger

trigger-infra:
  trigger:
    project: hipaa-app-org/infrastructure
    branch: main
  <<: *child-trigger

trigger-networking:
  trigger:
    project: hipaa-app-org/networking
    branch: main
  <<: *child-trigger

trigger-security:
  trigger:
    project: hipaa-app-org/security
    branch: main
  <<: *child-trigger

A small note on the monorepo-vs-polyrepo question. For healthcare teams under 30 engineers, my default is monorepo plus include:. The polyrepo split makes sense when the team boundaries are firm, code review ownership is genuinely separable, and each repo is large enough on its own that combining them slows everyone down. The team in this engagement was past that threshold; their five repos already existed for organizational reasons unrelated to CI. The parent/child pattern works either way. Don't split repos to support the pattern; the pattern supports whichever split you already have.

Section 06Compliance runner tags as a deploy boundary

GitLab self-hosted runners register with one or more tags. The pipeline declares which tags it needs. GitLab routes the job to a runner whose tags match. The tag becomes a structural compliance control because the alternative requires an admin to register a new runner.

Three runner pools in this engagement:

# /etc/gitlab-runner/config.toml on the prod-only runner host
# Three runners on this host, one per environment. The IAM
# binding is scoped via Workload Identity on the GKE node pool.

[[runners]]
  name = "hipaa-prod-runner-01"
  url = "https://gitlab.com/"
  token = "REDACTED"
  executor = "kubernetes"

  [runners.kubernetes]
    namespace = "gitlab-runners-prod"
    service_account = "hipaa-prod-runner-sa"
    image = "registry.example.com/runner-images/hipaa:v1.4.2"
    pull_policy = "always"
    # Tag enforced by GitLab: jobs without this tag won't land here
    [runners.kubernetes.node_selector]
      "stonebridge.io/runner-pool" = "hipaa-prod"
    [[runners.kubernetes.volumes.secret]]
      name = "hipaa-prod-kms-rolebinding"
      mount_path = "/etc/runner/kms"
      read_only = true

The runner image is signed and version-pinned. The pod runs on a tainted node pool that only accepts pods tolerating the runner taint. The runner's pod service account is bound to a GCP service account via Workload Identity; the GCP SA can deploy to the prod GKE cluster and nothing else.

The combined effect: a job in the parent that declares tags: ["hipaa-prod-runner"] can only land on this runner, which can only deploy to one cluster, which can only happen after the policy gate allowed. The pipeline YAML cannot lift its own privileges.

Section 07Where Argo CD comes in

The parent pipeline above triggers a deploy by calling kubectl directly. For most HIPAA engagements past a certain size, I move that deploy step to Argo CD with GitOps semantics. The parent pipeline writes a signed manifest update to a config repo; Argo CD reconciles the cluster against the config repo; the deploy event lands in Argo CD's audit log as well as the parent's evidence bundle.

The HIPAA-relevant property is that the running cluster state is continuously verified against the config repo, not just at deploy time. Drift detection becomes a control rather than a periodic check.

I'll cover the Argo CD side in depth in upcoming Kubernetes platform engineering posts. For the parent/child architecture, what matters is that the deploy stage's interface stays the same: the parent emits a signed deploy event, the evidence bundle records it, and downstream consumers can reconcile.

Section 08What the rebuild looked like at the 32-host platform

The team I opened with had 32 production VM-backed services on GCP, a 1,200-line monorepo pipeline, and five weeks before their 3PAO assessment. Their compliance officer wanted to know which hosts complied with the required baseline (Tomcat 9.0.62+, Node.js 18+, Ubuntu 20.04+). They had not had the cycles to walk every host.

We split the engagement in two. Track one was the host inventory: an Ansible plus Python tool that connected to each VM through GCP Identity-Aware Proxy, scraped the running versions, and produced a compliance matrix. The compliance team had the inventory inside a week. Track two was the pipeline rebuild.

The pipeline rebuild took four weeks. We split the 1,200-line file into a 60-line parent in a new compliance-platform repo and five children in their existing service repos (backend, frontend, infrastructure, networking, security). The polyrepo split predated us; the team had organized along those lines a year earlier for code review reasons. The parent/child pattern took advantage of the split instead of fighting it.

Three structural changes carried the audit:

The audit cleared on first-party review. The next quarterly internal review passed without remediation work because the architecture made future regressions structurally difficult. The 32-host inventory tool became part of the compliance team's continuous monitoring practice. The pipeline rebuild was the lasting change.

The same pattern is what we recommend on every GitLab-based HIPAA engagement now, regardless of cloud. The shape stays constant; the runner infrastructure and the deploy target swap by cloud.

Section 09Tooling recommendations

Opinionated picks for GitLab on HIPAA. The architecture matters more than the tool.

Layer Recommended Acceptable Avoid
Pipeline structure Parent in compliance repo, children in service repos Parent + children in same monorepo via include: Single thousand-line .gitlab-ci.yml
Cross-repo orchestration trigger: project: with strategy: depend Pipeline scheduling via API Cron jobs that hope children are fresh
Artifact contract Structured reports: (container_scanning, sast, dotenv) JSON artifacts collected via API Logs scraped from job traces
Runner isolation Three runner pools per environment, tag-enforced Two pools (build, deploy) with scoped IAM One shared runner pool with broad IAM
Policy gate OPA in the parent, evidence bundle as input Conftest in a required job Slack notification, advisory only
Deploy Argo CD pulling from config repo kubectl in the prod runner SSH and shell scripts
Evidence storage GCS Bucket Lock or S3 Object Lock, 6-year retention Versioned bucket with retention policy GitLab artifact storage alone

Section 10Common mistakes to avoid

Five quick callouts. Each one shows up in real GitLab pipelines I'm called in to fix.

The longer-form versions of these failure modes live in five patterns that fail HIPAA audits.

Section 11Conclusion

GitLab's parent/child primitives are the strongest any major CI platform offers for HIPAA work. The teams that use them well treat the boundary between parent and child as a compliance control, not a stylistic preference.

The parent owns the gates: identity verification, evidence aggregation, policy gating, deploy authorization. The compliance team owns the parent. The children own the services: build, test, scan, sign, emit. Service teams own each child. The artifact contract between them is the only interface. Tag-scoped runners make the deploy boundary structural; multi-project triggers handle polyrepo splits without compromising the gates.

Build the pipeline this way and the auditor's questions become queries against the evidence bundle. Build it the other way and you spend the next assessment cycle rebuilding what you should have built once.

If you're running GitLab in a HIPAA environment: Stonebridge runs two-week HIPAA CI/CD audits that map your existing GitLab pipeline against the Security Rule and produce a written remediation roadmap. Fixed fee, founder-led, the report holds up under first-party review by your auditor.

Keep reading: the broader architecture pattern across CI platforms is in the HIPAA CI/CD implementation guide. The parallel post for teams on GitHub Actions is GitHub Actions for HIPAA-compliant deployments. The pre-audit walkthrough of every Security Rule control is in the HIPAA CI/CD audit checklist.


About the author

Lucas Jones, Founder

Founder and Principal Platform Engineer at Stonebridge Tech Solutions. Six years building cloud infrastructure and CI/CD pipelines in regulated environments, including HIPAA, FedRAMP, and SOC 2 work for healthcare and defense engineering teams across AWS, GCP, Azure, and OCI.

See how we engage on HIPAA CI/CD work →