Skip to Content
OperationsCI/CD Pipeline

CI/CD Pipeline

GitLab CI, modelled on the meridian/* repos, adapted to this monorepo.

Goals

  • Selective on push — a push builds and deploys only the unit(s) whose files changed.
  • Manual full deploy — a one-click way to build and deploy everything.
  • Fast images — backend images use layered Dockerfiles (dependency layer cached separately from the thin application layer).

Shape

Parent/child pipeline. The root .gitlab-ci.yml has one trigger job per deployable unit; each includes a shared child template — one backend template, one frontend template, no per-service copies.

UnitsTemplateStages
api-gateway, core-engine, credential-issuance, verification-engine, issuer-registry, admin-console.gitlab-ci/backend.ymlversion → build → image → deploy
credential-wallet, issuer-portal, verifier-portal, admin-console (UI), landing.gitlab-ci/frontend.ymlversion → image → deploy

The child template is parameterised by a single SERVICE variable (plus APP/DOCKERFILE for frontends) passed from the parent trigger job.

When each unit runs

Each parent trigger job’s rules, in order:

  1. FULL_DEPLOY == "true" — manual full deploy.
  2. CI_PIPELINE_SOURCE == "web" || "api" — a plain Run pipeline / API trigger does everything.
  3. CI_PIPELINE_SOURCE == "push" and changes: matches — selective.

changes: for a backend service = its own backends/<svc>/** plus the shared contract: backends/shared/**, build.gradle, settings.gradle, gradle/**, gradle.properties, gradlew. Touching shared code rebuilds all six services. Frontend changes: = frontends/<app>/** + its Dockerfile + the nginx conf.

Full deployment (manual step)

GitLab → Build → Pipelines → Run pipeline, branch develop, set FULL_DEPLOY = true. Every unit builds and deploys, changes: filters ignored. (A plain Run pipeline with no variables does the same via rule 2.)

Image tagging

$CI_REGISTRY_IMAGE/<unit>:<immutable> where <unit> is the compose service name, plus a moving channel tag:

BranchImmutable versionChannel tag
develop<base>-dev.<pipeline-id>dev
main<base>latest
release/*<base>-rc.<pipeline-id>rc
feature/*<base>-feat.<pipeline-id>feat

<base> comes from backends/<svc>/build.gradle or frontends/<app>/package.json.

Initial infra setup (once per host)

The stateful backbone is its own compose project, brought up by a manual, non-blocking job — not by the per-service deploy jobs.

deployment/docker-compose.infra.yml holds Postgres, PgBouncer, Redis, the nginx ingress, and the read-only docker-proxy log sidecar (tecnativa/docker-socket-proxy, CONTAINERS=1, POST=0 — a GET-only window onto the Docker Engine API); owns the shared attest-pro Docker network and the postgres_data/redis_data/pgbouncer_logs volumes.

The Admin Console backend reaches docker-proxy at DOCKER_PROXY_URL (http://docker-proxy:2375) to tail every attest-pro-* container in the console’s Logs → System logs tab, without mounting /var/run/docker.sock itself. deployment/docker-compose.deploy.yml (the app tier) joins the network as external and defines no infra services.

# setup-infra job (stage infra, when: manual, allow_failure: true, develop only) docker compose -p attest-pro -f deployment/docker-compose.infra.yml pull docker compose -p attest-pro -f deployment/docker-compose.infra.yml down -v docker compose -p attest-pro -f deployment/docker-compose.infra.yml up -d

Every run is a clean start — down -v removes the data volumes before up, so Flyway re-applies V1/V2 on the next app deploy. Redeploy the app stack after running it; don’t run it against an environment whose data you need to keep. Run it once when first provisioning the host, and again only after editing docker-compose.infra.yml, deployment/pgbouncer/*, or deployment/nginx/proxy.nginx.conf.template.

  • pgbouncer bind-mounts deployment/pgbouncer/pgbouncer.ini/userlist.txt from the repo checkout — works when attest-deploy is a shell executor. For a docker/socket executor, pre-place those files on the host or bake them into a custom image.
  • No schema/user materialisation needed — the admin role comes from POSTGRES_USER/POSTGRES_PASSWORD; every application schema is created by that service’s Flyway migration on first boot.
  • deployment/scripts/postgres-init.sh is wired into /docker-entrypoint-initdb.d/; it only creates the low-privilege pgbouncer role and pgbouncer.user_lookup() for auth_query.
  • First boot: bring up infra, then run a full deploy (FULL_DEPLOY=true) so every service starts and its Flyway migrations create the schemas.

Connection pooling (PgBouncer)

Transaction-mode pool on pgbouncer:6432.

PathEndpointWhy
App runtime SQL (Hikari)pgbouncer:6432pooled; ?prepareThreshold=0 (transaction pooling forbids server-side prepared statements)
Flyway migrationspostgres:5432session-scoped advisory locks are incompatible with transaction pooling

Each DB-backed service’s application.yml takes spring.datasource.url from ${DB_URL:…} and an explicit spring.flyway.url from ${FLYWAY_DB_URL:…}. Defaults point direct at postgres:5432, so local runs and non-pooled envs are unchanged.

Auth is auth_query against pg_shadow via pgbouncer.user_lookup() — deployment/pgbouncer/userlist.txt holds only the bootstrap pgbouncer role (never add per-service rows). Its password must match PGBOUNCER_AUTH_PASSWORD given to the postgres container — both default to a baked-in random value in three places that must stay in sync: userlist.txt, postgres-init.sh, and the compose-file default. For real deployments, set a fresh PGBOUNCER_AUTH_PASSWORD and the matching userlist.txt line.

edoburu/pgbouncer does not hot-reload — a config edit needs docker restart attest-pro-pgbouncer (or re-run setup-infra).

Local dev: deployment/docker-compose.yml includes pgbouncer in the backends profile. Run ./deploy.sh docker clean once (postgres data volume is persistent) so postgres-init.sh executes on the fresh volume — otherwise pgbouncer can’t authenticate and dependent services won’t start.

Deploy

Single environment, automatic on develop. The deploy job runs on a host runner tagged attest-deploy and, for the changed unit only:

docker compose -p attest-pro -f deployment/docker-compose.deploy.yml pull <svc> docker compose -p attest-pro -f deployment/docker-compose.deploy.yml up -d --no-deps <svc>

resource_group: <svc> serialises deploys of the same service. main builds images but does not deploy (kept for a future promotion gate).

Host prerequisites

  • A registered GitLab runner tagged attest-deploy, Docker socket access, on the deploy host.
  • setup-infra run once (creates the attest-pro network + data tier).
  • Runtime secrets provided as environment/.env beside the compose files — not committed: DB_ADMIN_PASSWORD, JWT_SECRET, SSO_ENCRYPTION_KEY, optional DOMAIN, DB_ADMIN_USER (default attestid_admin), SPRING_PROFILES_ACTIVE (default prod), and each service’s least-privilege DB credential (ISSUER_REGISTRY_DB_PASSWORD, CREDENTIAL_ISSUANCE_DB_PASSWORD, CORE_ENGINE_DB_PASSWORD, ADMIN_SERVICE_DB_PASSWORD — see Database Schema Ownership).
  • CI passes REGISTRY_IMAGE_PREFIX=$CI_REGISTRY_IMAGE and IMAGE_TAG=<channel> to compose.

deployment/docker-compose.yml (build + profiles, used by ./deploy.sh) is unchanged — it stays the local-dev stack.

Comparison to the Meridian pattern

AspectMeridianAttest ProTrade-off
Config filesExternal INFRA_HOST_DIR on host; CI syncs repo → hostAll in repo (deployment/)✅ version-controlled, no drift
File materialisationdocker run --rm -v helpers sync configs before compose upConfig already on host (part of checkout)✅ fewer moving parts

Attest Pro’s approach works because attest-deploy is assumed to be a shell executor (repo checked out on the Docker host). If your runner is a Docker socket executor, either pre-place deployment/pgbouncer/*.ini and deployment/scripts/*.sh on the host, or use Meridian’s materialisation pattern.

Verification checklist

8 tests to run before declaring CI/CD ready:

  1. Path filtering, changed backend — edit backends/api-gateway/..., push to develop. Expected: only api-gateway triggers (test → build → image → deploy); other backends/frontends skip; byok-oxford-demo runs.
  2. Path filtering, shared code — edit backends/shared/src/main/proto/credential.proto. Expected: all 6 backend services trigger.
  3. Path filtering, frontend — edit frontends/credential-wallet/.... Expected: only credential-wallet triggers.
  4. Full deploy override — FULL_DEPLOY=true. Expected: all 11 services build and deploy regardless of file changes.
  5. Image tagging — docker image ls | grep attest-pro shows both immutable and moving tags.
  6. Service health — docker compose ... ps shows healthy/running; curl -sf http://localhost:8080/actuator/health | jq '.status' returns "UP".
  7. Env vars injected — docker exec attest-pro-api-gateway env | grep JWT_SECRET is set.
  8. Post-deploy validation (BYOK demo) — byok-oxford-demo job logs show a registered issuer, issued credential, and Verified signature: PASS.

Common debugging scenarios

SymptomCauseFix
Pipeline doesn’t trigger on pushBranch isn’t develop, or changes: doesn’t matchgit branch -vv; merge to develop
Image push 403 ForbiddenRegistry credentials missing/wrongSet CI_REGISTRY_USER/CI_REGISTRY_PASSWORD (protected vars); verify with docker login
Service fails: connection refusedPostgres not ready, or dependency unhealthydocker logs <container>; check Postgres/pgbouncer logs; verify DB_URL points to pgbouncer:6432
Stale postgres_data blocks migrationOld schema conflicts with new Flyway migrationRe-run setup-infra (runs down -v), then full deploy
pgbouncer unhealthy / can’t authpgbouncer.ini/userlist.txt out of syncVerify PGBOUNCER_AUTH_PASSWORD matches; re-run setup-infra if pgbouncer.ini changed
Test stage fails, code looks correctGradle cache corruption or stale proto stubs./gradlew clean :backends:shared:compileProto --refresh-dependencies

Operational runbook

One-time host setup:

  1. Provision runner (tag attest-deploy, shell or Docker socket executor, docker/docker compose, ~200 GB disk).
  2. Set GitLab protected CI/CD variables: CI_REGISTRY_USER, CI_REGISTRY_PASSWORD.
  3. Create .env on host beside docker-compose.infra.yml with per-service DB passwords, JWT_SECRET, PGBOUNCER_AUTH_PASSWORD, DOMAIN, SPRING_PROFILES_ACTIVE.
  4. Run setup-infra manually (Build → Pipelines → Run pipeline, branch develop) — completes in ~60 seconds.
  5. Verify: docker compose -p attest-pro -f deployment/docker-compose.infra.yml ps — all Up.

Per-deploy workflow: push to develop → pipeline auto-triggers → version → test (backend only) → build → docker-image → deploy → byok-oxford-demo validation.

Basic monitoring (no Prometheus/Grafana yet):

docker compose -p attest-pro -f deployment/docker-compose.deploy.yml ps docker logs attest-pro-core-engine -f docker exec attest-pro-credential-issuance psql -h pgbouncer -U credential_issuance_user -d attest_pro -c "\dt" docker exec attest-pro-redis redis-cli ping # PONG

Not included / next steps

  • Unit/integration tests run locally today; the CI test job is commented out (uncomment in .gitlab-ci/backend.yml to re-enable). Frontend template has no test/typecheck stage yet.
  • Staging/production split — on the production launch backlog, not yet built: a deploy rule for main with when: manual + a second docker-compose.<env>.yml, with a promotion gate ahead of production. See CLAUDE.md’s Roadmap Context.
  • Kubernetes — replace the deploy job with kubectl/helm once manifests exist. Not on the critical path for the 2026-11-12 launch target; single-host Docker Compose is judged adequate for the first marquee issuer.