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.
| Units | Template | Stages |
|---|---|---|
api-gateway, core-engine, credential-issuance, verification-engine, issuer-registry, admin-console | .gitlab-ci/backend.yml | version → build → image → deploy |
credential-wallet, issuer-portal, verifier-portal, admin-console (UI), landing | .gitlab-ci/frontend.yml | version → 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:
FULL_DEPLOY == "true"— manual full deploy.CI_PIPELINE_SOURCE == "web" || "api"— a plain Run pipeline / API trigger does everything.CI_PIPELINE_SOURCE == "push"andchanges: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:
| Branch | Immutable version | Channel 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 -dEvery 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.
pgbouncerbind-mountsdeployment/pgbouncer/pgbouncer.ini/userlist.txtfrom the repo checkout — works whenattest-deployis 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.shis wired into/docker-entrypoint-initdb.d/; it only creates the low-privilegepgbouncerrole andpgbouncer.user_lookup()forauth_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.
| Path | Endpoint | Why |
|---|---|---|
| App runtime SQL (Hikari) | pgbouncer:6432 | pooled; ?prepareThreshold=0 (transaction pooling forbids server-side prepared statements) |
| Flyway migrations | postgres:5432 | session-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-infrarun once (creates theattest-pronetwork + data tier).- Runtime secrets provided as environment/
.envbeside the compose files — not committed:DB_ADMIN_PASSWORD,JWT_SECRET,SSO_ENCRYPTION_KEY, optionalDOMAIN,DB_ADMIN_USER(defaultattestid_admin),SPRING_PROFILES_ACTIVE(defaultprod), 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_IMAGEandIMAGE_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
| Aspect | Meridian | Attest Pro | Trade-off |
|---|---|---|---|
| Config files | External INFRA_HOST_DIR on host; CI syncs repo → host | All in repo (deployment/) | ✅ version-controlled, no drift |
| File materialisation | docker run --rm -v helpers sync configs before compose up | Config 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:
- Path filtering, changed backend — edit
backends/api-gateway/..., push todevelop. Expected: onlyapi-gatewaytriggers (test → build → image → deploy); other backends/frontends skip;byok-oxford-demoruns. - Path filtering, shared code — edit
backends/shared/src/main/proto/credential.proto. Expected: all 6 backend services trigger. - Path filtering, frontend — edit
frontends/credential-wallet/.... Expected: onlycredential-wallettriggers. - Full deploy override —
FULL_DEPLOY=true. Expected: all 11 services build and deploy regardless of file changes. - Image tagging —
docker image ls | grep attest-proshows both immutable and moving tags. - Service health —
docker compose ... psshowshealthy/running;curl -sf http://localhost:8080/actuator/health | jq '.status'returns"UP". - Env vars injected —
docker exec attest-pro-api-gateway env | grep JWT_SECRETis set. - Post-deploy validation (BYOK demo) —
byok-oxford-demojob logs show a registered issuer, issued credential, andVerified signature: PASS.
Common debugging scenarios
| Symptom | Cause | Fix |
|---|---|---|
| Pipeline doesn’t trigger on push | Branch isn’t develop, or changes: doesn’t match | git branch -vv; merge to develop |
| Image push 403 Forbidden | Registry credentials missing/wrong | Set CI_REGISTRY_USER/CI_REGISTRY_PASSWORD (protected vars); verify with docker login |
| Service fails: connection refused | Postgres not ready, or dependency unhealthy | docker logs <container>; check Postgres/pgbouncer logs; verify DB_URL points to pgbouncer:6432 |
Stale postgres_data blocks migration | Old schema conflicts with new Flyway migration | Re-run setup-infra (runs down -v), then full deploy |
| pgbouncer unhealthy / can’t auth | pgbouncer.ini/userlist.txt out of sync | Verify PGBOUNCER_AUTH_PASSWORD matches; re-run setup-infra if pgbouncer.ini changed |
| Test stage fails, code looks correct | Gradle cache corruption or stale proto stubs | ./gradlew clean :backends:shared:compileProto --refresh-dependencies |
Operational runbook
One-time host setup:
- Provision runner (tag
attest-deploy, shell or Docker socket executor,docker/docker compose, ~200 GB disk). - Set GitLab protected CI/CD variables:
CI_REGISTRY_USER,CI_REGISTRY_PASSWORD. - Create
.envon host besidedocker-compose.infra.ymlwith per-service DB passwords,JWT_SECRET,PGBOUNCER_AUTH_PASSWORD,DOMAIN,SPRING_PROFILES_ACTIVE. - Run
setup-inframanually (Build → Pipelines → Run pipeline, branchdevelop) — completes in ~60 seconds. - Verify:
docker compose -p attest-pro -f deployment/docker-compose.infra.yml ps— allUp.
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 # PONGNot included / next steps
- Unit/integration tests run locally today; the CI
testjob is commented out (uncomment in.gitlab-ci/backend.ymlto re-enable). Frontend template has notest/typecheckstage yet. - Staging/production split — on the production launch backlog, not yet built: a
deployrule formainwithwhen: manual+ a seconddocker-compose.<env>.yml, with a promotion gate ahead of production. See CLAUDE.md’s Roadmap Context. - Kubernetes — replace the
deployjob withkubectl/helmonce 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.