Skip to content
Portfolio CI/CD Pipeline

Portfolio CI/CD Pipeline

Overview

A fully automated end-to-end CI/CD system spanning three GitLab repositories - website, infrastructure, and Ansible - wired together via cross-repo triggers. A single git tag drives the entire journey from Hugo build to live production deployment, while infrastructure changes flow through Terraform into Ansible-driven provisioning. Supports two isolated environments: staging and prod.

Period: Jan 2026 – Mar 2026 (gradually updating…)| Role: Solo - DevOps Engineer


Architecture

Pipeline Architecture Diagram


Tech Stack

  • CI/CD: GitLab CI/CD (cross-repo pipeline triggers · strategy: depend)
  • Static Site: Hugo Extended · Docker · Nginx
  • Infrastructure as Code: Terraform · DigitalOcean provider · Cloudflare DNS
  • Configuration Management: Ansible · Ansible Vault · Galaxy collections
  • Environments: staging · prod (DigitalOcean Droplets)
  • Reverse Proxy: Traefik (TLS termination)
  • Secrets: Ansible Vault (encrypted vars) · HashiCorp Vault · GitLab masked CI/CD variables
  • Security & Compliance: Gitleaks (secret scanning) · Checkov (IaC SAST) · Open Policy Agent via Conftest (Rego policies)

Pipeline Flow

Website Repo → Deploy

StageJobPurpose
securitysecret-scan, hadolint-dockerfile, create-sbom, trivy-sbom-scan, trivy-misconfig-scan, semgrep-sastShift-left security checks (MR to main only)
buildhugo-buildBuild Hugo site
packagedocker-buildBuild + push image tags ${CI_COMMIT_SHORT_SHA} and latest on push to main
packagetag-image-from-commitRetag commit image to ${CI_COMMIT_TAG} on tag pipelines
deploydeploy-stagingDeploy to staging
deploydeploy-prodDeploy to production
  • hugo-build and docker-build run only on push to main (not on MR).
  • Tag convention: v1.2.3-stag deploys to staging, while v1.2.3 (without -stag) deploys to prod.

Note

Deploy jobs run from tag pipelines and trigger the Ansible repo (strategy: depend) with TARGET_ENV, PORTFOLIO_IMAGE, and TRIGGER_SOURCE=website. The website pipeline waits for downstream deploy result and mirrors its status.


Infrastructure Repo → Provision

Direct pushes to main are blocked. Changes are expected to go through Merge Requests, and workflow: rules limits valid pipelines to MR targeting main and push to main.

MR to main

StageJobPurpose
lintterraform-fmt, tflintFormat check + static analysis
securitysecret-scan (Gitleaks), sast-scan (Checkov)Secret detection + IaC SAST

Push to main (MR get merged)

StageJobTrigger
planplan-staging, plan-prodfile changes scoped per env
compliancecompliance-staging, compliance-prodOPA / Conftest · custom Rego policies
applyapply-staging, apply-prodmanual (allow_failure: true)
provisiontrigger-provision-staging, trigger-provision-prodmanual cross-repo trigger to Ansible (strategy: depend)
cleanupcleanup-staging, cleanup-prodmanual (safety valve - tears down env)
  • Plan jobs are automatically triggered by changes to the main branch, but they only run if the changes affect files relevant to that environment (e.g. staging.tfvars or prod.tfvars), preventing unnecessary plans for unrelated changes.
  • Compliance jobs run Conftest against the Terraform plan output, validating it against custom Rego policies that enforce rules like “no destructive actions without explicit approval”, “Droplets must be of certain sizes”, “DNS records must follow specific patterns”, and “Vault configuration must meet security standards”.
  • Apply jobs are manual gates that only become available after a successful compliance check, ensuring that no infrastructure change can be applied without first passing the OPA policy validation.
  • Provision jobs are manual and depend on successful apply-* jobs, then trigger Ansible downstream provisioning.
  • Cleanup jobs are manual gates that allow for tearing down the entire environment if needed, providing a safety valve in case of misconfigurations or failed deployments (considered this project is fairly small)

Web pipeline only

StageJobTrigger
vaultcreate-vault, vault-bootstrap, destroy-vaultweb pipeline only when VAULT=true (destroy-vault is manual)

Warning

Vault lifecycle is intentionally isolated from the standard infra flow. It is run from a web-triggered pipeline (VAULT=true) for controlled, operator-driven Vault bootstrap/teardown.


Ansible Repo

MR to main

StageJobPurpose
Lintlintplaybook lint + syntax checks
Securitysecret-scan, ansible-misconfig-scansecret and Ansible misconfiguration scanning

misconfig-scan job uses Checkov’s Ansible scanning capabilities to detect common issues like hardcoded secrets, unsafe permissions, and deprecated modules.

Note

While there is some skipped checks (e.g. Vault health check task that targets localhost before TLS is configured), the majority of the playbook code is covered by these automated gates, ensuring high quality and security standards before any change is merged.

When triggered by upstream pipelines (or web manual)

StageJobPurpose
Playbookprovisioninfra-triggered pipeline (TARGET_ENV set, PORTFOLIO_IMAGE unset)
Playbookdeploywebsite-triggered pipeline (TRIGGER_SOURCE=website, PORTFOLIO_IMAGE set)
Playbookvault-bootstraptriggered by infrastructure Vault pipeline when VAULT=true
  • Lint/security gates run on MR pipelines targeting main.
  • Provision job runs the Ansible playbook that bootstraps the entire staging or prod environment from scratch - from Droplet creation to Traefik configuration.
  • Deploy job runs the Ansible playbook that deploys the determined Hugo Docker image to the live environment, without touching the underlying infrastructure.

Note

The pipeline separates provisioning and deployment by rule conditions: provision runs only when PORTFOLIO_IMAGE is absent, while deploy requires both TRIGGER_SOURCE=website and PORTFOLIO_IMAGE.


Rollback Strategy

  • Application Rollback: Previous Hugo image tags are retained in the container registry. To rollback, manually trigger the deploy job with the desired image tag (e.g. ${CI_COMMIT_SHORT_SHA} or latest from a known good commit).

Key Achievements

  • Cross-repo trigger chain: websiteansible and infrastructureansible with strategy: depend. The triggering pipeline waits for the downstream to finish and reflects its status.
  • Shift-left security gates: Website, infrastructure, and ansible repos run security checks on MR pipelines targeting main, catching issues before merge.
  • OPA compliance gate: Conftest validates each Terraform plan JSON against custom Rego policies (destructive operations, DNS, Droplet sizing, Firewall rules, Vault) before the apply job is unlocked.
  • Environment isolation: Staging and prod apply, provision, and cleanup are explicit manual gates in infra pipeline.
  • Vault lifecycle pipeline: A dedicated web pipeline (VAULT=true) creates Vault infra, triggers ansible vault-bootstrap, and supports manual destroy.
  • DRY pipeline YAML: Ansible jobs reuse shared templates (.ansible_base, .ansible_setup) via extends.
  • Secrets management: Ansible Vault encrypts all sensitive vars at rest; SSH private key is stored base64-encoded in GitLab masked variables and decoded at runtime, never written in plaintext.
  • Selective infra plans: Plan jobs use changes: filters scoped to per-env tfvars and backend files, so a prod tfvars edit doesn’t trigger staging plans - and vice versa.
  • Deterministic artifact promotion: Website release tags are promoted from an already-built commit image (tag-image-from-commit) instead of rebuilding at release time, reducing drift between tested and deployed artifacts.
  • Policy-before-change enforcement: Infra jobs persist plan JSON artifacts and gate apply behind Conftest checks, making policy decisions reproducible and reviewable.
  • Environment-scoped deployment logic: Ansible execution is guarded by explicit runtime conditions (TARGET_ENV, TRIGGER_SOURCE, PORTFOLIO_IMAGE) to prevent accidental cross-flow execution.
  • Ephemeral SSH access model: Ansible setup supports Vault-signed short-lived SSH certificates (with bootstrap fallback), minimizing long-lived key exposure in routine operations.

Pipeline Screenshots

Pipeline run

Pipeline overview

Gitlab CI job log