15 KiB
Voting App — Mono-Repo CI/CD
A full production-shaped CI/CD pipeline built around Docker's example-voting-app reference architecture, as a self-hosted GitLab DevOps coursework project. Every service (vote, result, worker) lives in one repository, with a single pipeline covering build, test, vulnerability scanning, multi-environment deployment with rollback, load testing, and automated database backups to object storage.
This is the mono-repo half of a two-part assignment. See Voting App — Multi-Repo CI/CD for the split-repository version covering the same requirements with vote, result, worker, and a devops project as independent GitLab projects.
Architecture
flowchart LR
User -->|votes a/b| Vote[vote\nPython/Flask]
Vote --> Redis[(Redis\nqueue)]
Redis --> Worker[worker\n.NET]
Worker --> Postgres[(Postgres\ndb)]
Postgres --> Result[result\nNode/Socket.IO]
Result -->|live %| User
- vote — Flask app, takes a vote, pushes it onto a Redis queue
- worker — .NET background service, drains the Redis queue, writes each vote into Postgres
- result — Node/Express app with Socket.IO, reads Postgres, pushes live results to the browser
- redis / postgres — infrastructure, not application code, but part of every environment's stack
Repository layout
voting-app-monorepo/
├── vote/
│ ├── Dockerfile
│ └── ... (Flask app source)
├── result/
│ ├── Dockerfile
│ └── ... (Node app source)
├── worker/
│ ├── Dockerfile
│ └── ... (.NET app source)
├── loadtest/
│ └── vote-load-test.js
├── docker-compose.deploy.yml
├── .gitignore
└── .gitlab-ci.yml
Pipeline overview
Six stages, each gating the next:
flowchart LR
build --> test --> scan --> deploy --> load-test --> backup
| Stage | Jobs | Trigger |
|---|---|---|
| build | build-vote, build-result, build-worker | automatic |
| test | test-vote, test-result, test-worker | automatic |
| scan | scan-vote, scan-result, scan-worker | automatic |
| deploy | deploy-dev, deploy-staging, deploy-prod | dev: automatic · staging/prod: manual |
| load-test | load-test-staging | manual |
| backup | backup-prod-db | manual |
All three services share a .build-service / .test-service / .scan-service hidden-job template pattern (extends:), the same approach used in Custom CI Runner Images — adding a fourth service later would be a handful of lines, not a duplicated block.
Build
Standard docker build + push to this project's own registry namespace (registry.gitlab.home/root/voting-app-monorepo/<service>), tagged both :latest and :$CI_COMMIT_SHORT_SHA. Uses the same host-socket Docker-outside-of-Docker pattern as every other project in this homelab — job containers build against the host's existing Docker daemon rather than a nested one, keeping RAM overhead down.
build-vote includes --pull specifically (see Scanning below for why); result and worker rely on Docker's layer cache normally, since forcing a fresh pull on every build isn't needed once the base image itself is patched.
Test
Lightweight smoke tests rather than the upstream project's full Selenium integration suite — a deliberate choice given this hardware's RAM constraints. Each service's built image is started standalone, and:
- vote / result — a separate
curlcontainer, on a throwaway per-job Docker network, hits the service's root endpoint and expects200 - worker — has no HTTP surface (it's a background queue consumer), so the check instead confirms the container is still running a few seconds after start — a crash-on-startup (bad connection string, missing dependency) shows up as an unexpected exit; a healthy worker just sits retrying its Redis/Postgres connections, which is expected in isolation from the other services
Scanning
Trivy, run as a hard gate (--exit-code 1), not just a report. Getting here took several real fixes, not just enabling a flag:
aquasec/trivy's image hasENTRYPOINThardcoded totrivyitself — GitLab's defaultsh -c "..."invocation becomestrivy sh -c "...", which fails withunknown command "sh". Fixed withimage: { name: ..., entrypoint: [""] }.- Trivy's vulnerability DB download failed with a 403 from
mirror.gcr.io— consistent with the same sanctions-related infrastructure blocks hit elsewhere in this homelab (see GitLab CI/CD). Fixed by overridingTRIVY_DB_REPOSITORYto pull fromghcr.ioinstead, and caching the downloaded DB via GitLab Runner's/cachemount so this only has to succeed once, not every pipeline run. --ignore-unfixed— added after discovering the base Debian image carries CVEs markedwill_not_fixby Debian itself (e.g.zlib1g) that no Dockerfile change could ever resolve. Without this flag, the gate would fail permanently on things genuinely outside this project's control.
Real, fixable findings that came out of this and were actually resolved, not suppressed:
| Service | Finding | Fix |
|---|---|---|
vote |
~30 Debian OS CVEs, all with real fix versions available (openssl, libsystemd0, perl-base, etc.) |
Added apt-get upgrade -y to the Dockerfile; forced --pull on build so the fix actually takes on rebuild |
worker |
Npgsql CVE-2024-32655 (HIGH), System.Drawing.Common CVE-2021-24112 (CRITICAL) |
Bumped Npgsql 4.1.9 → 8.0.3 in Worker.csproj; explicitly pinned System.Drawing.Common 5.0.3 (a transitive dependency, not directly referenced, that was resolving to an old vulnerable version) |
result |
Dozens of findings (tar, sigstore, minimatch, ip-address) that turned out to be bundled inside npm's own internal tooling, not the app's real dependencies |
Removed the global npm/npx/corepack install from the production image stage entirely — the container only ever runs node server.js, never npm, so this tooling was pure unused attack surface |
result |
socket.io-parser/ws outdated within the existing ^4.7.2 semver range |
npm update — non-breaking, respects the existing version constraint |
vote |
Two vendored CVEs (jaraco.context, wheel) bundled inside an old setuptools |
Left as a documented .trivyignore exception — the real fix (pip install --upgrade pip setuptools) requires reaching files.pythonhosted.org, which is unreachable from this network (confirmed: pypi.org resolves fine, files.pythonhosted.org times out — a different, narrower gap than the sanctions-blocked registries elsewhere in this project, cause not fully diagnosed). Documented with a dated comment rather than silently ignored. |
Deploy — three environments
services:
redis: {...}
db: {...}
vote:
image: "${CI_REGISTRY_IMAGE}/vote:${IMAGE_TAG}"
ports: ["${VOTE_PORT}:80"]
result:
image: "${CI_REGISTRY_IMAGE}/result:${IMAGE_TAG}"
ports: ["${RESULT_PORT}:80"]
worker: {...}
One parameterized docker-compose.deploy.yml, reused by all three environments — only IMAGE_TAG, port numbers, and the Compose project name (-p) differ between them, which is what keeps environments fully isolated on a single host:
| Environment | Compose project | Ports (vote/result) | Trigger | Lifecycle |
|---|---|---|---|---|
| dev | voting-dev |
5100/5101 | automatic, every push | ephemeral — spun up, smoke-tested via docker compose exec, torn down (down -v) in the same job |
| staging | voting-staging |
5200/5201 | manual | persistent |
| production | voting-prod |
5300/5301 | manual | persistent |
Every service has an explicit mem_limit in the compose file — a deliberate guardrail, not an afterthought, given this host has as little as ~2.7GB free at times and up to three environments' worth of containers could theoretically exist at once.
Rollback and rollout
environment: name: staging / name: production on each deploy job is what makes GitLab track full deployment history natively — no custom rollback scripting needed, just structuring the jobs correctly.
Proven live, not just configured: after two real deployments to production (the original content, then a deliberate template edit), the Environments UI showed full history —
Clicking the rollback icon on the earliest deployment redeployed that exact image, confirmed by the browser reverting to the original content with zero terminal interaction — and GitLab recorded it as a new deployment entry rather than rewriting history, preserving a complete, honest audit trail:
Load testing
k6, chosen over Locust/JMeter for being a single static binary with no separate agent/server to run — matters on constrained hardware. Runs against staging, never production, as a manual job.
export const options = {
stages: [
{ duration: '10s', target: 5 },
{ duration: '20s', target: 5 },
{ duration: '5s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<1000'],
http_req_failed: ['rate<0.05'],
},
};
Deliberately modest load (5 virtual users, ~35s) — proving the mechanism and getting real latency data, not stress-testing to failure on hardware this constrained. thresholds make this a genuine gate: exceeding p95 latency or failure-rate limits fails the job, not just logs a warning.
Hit the same ENTRYPOINT-hardcoded-to-the-tool-itself issue as Trivy — grafana/k6's image needed the identical entrypoint: [""] override.
Backup to object storage
Automated pg_dump of the production database, compressed and pushed to MinIO (self-hosted, -cpuv1 release tag — see Custom CI Runner Images for why this hardware needs that specific tag).
docker exec voting-prod-db-1 pg_dump -U postgres postgres | gzip > "$BACKUP_FILE"
docker create --name mc-helper-$CI_JOB_ID --entrypoint sh minio/mc:RELEASE.2025-08-13T08-35-41Z-cpuv1 -c "sleep 300"
docker start mc-helper-$CI_JOB_ID
docker cp "$BACKUP_FILE" mc-helper-$CI_JOB_ID:/tmp/"$BACKUP_FILE"
docker exec mc-helper-$CI_JOB_ID mc cp /tmp/"$BACKUP_FILE" localminio/postgres-backups/
The real lesson here: the first version of this job used docker run -v "$(pwd)":/backup ... to hand the dump file to a throwaway mc container, and it silently failed — mc reported the file "not found" despite the dump succeeding. The cause: this job's docker run calls execute against the host's Docker daemon (Docker-outside-of-Docker), so a bind mount (-v) always resolves against the host's filesystem — never the CI job container's own filesystem, regardless of which container issued the command. $(pwd) inside the job container is a path that simply doesn't exist on the host. The fix was switching to docker cp, which streams the file over the Docker API connection itself rather than depending on a shared filesystem path — the correct tool for moving a file into a container across a DooD boundary.
Registry hygiene
24-25 tags accumulated per service purely from iterative pipeline debugging during development. A cleanup policy was configured (keep 10 most recent, remove tags older than 30 days, no keep-pattern — see the regex gotcha below), but running it via the UI's "Run cleanup now" and via the Rails console both returned deleted_size: 0 despite status: :success — a known limitation on self-managed GitLab instances without the registry's metadata database feature enabled. Rather than chase that setup for a learning project, tags were pruned manually through the UI as a one-time pass, down to the 3 most recent per service.
Trade-off worth naming honestly: keeping only 3 tags means any rollback attempt reaching further back than that would fail — the deployment history entry would still exist in GitLab, but the underlying image tag it depends on would no longer be pullable. In a real production system this is exactly why cleanup policies are usually tuned generously (10-20+ tags) rather than aggressively; disk is cheap, losing the ability to roll back during a real incident is not. Three was a deliberate, informed trade-off for this project's disk constraints, not an oversight.
Lessons learned
- Images with
ENTRYPOINThardcoded to the tool itself (trivy,k6,mc) break GitLab's defaultsh -c "..."script invocation with a confusing "unknown command sh" error — the fix (entrypoint: [""]) is the same every time, but only obvious once you've hit it once. - Docker-outside-of-Docker bind mounts (
-v host:container) always resolve against the host's filesystem, regardless of which container issued thedocker runcommand —docker cpis the correct tool for moving a file across that boundary, not a shared path. - A vulnerability scanner reporting hundreds of findings in a Node/npm-based image doesn't necessarily mean the app's own dependencies are outdated — bundled tooling (
npm's own internal packages, unused dev tools left in a production image) can dominate the report. Splitting build stages so production images only contain what they actually run at runtime resolves this at the source. --ignore-unfixedis necessary for a vulnerability scan to be a useful gate on any Debian-based image — some CVEs are permanentlywill_not_fixby the distro itself, and failing a pipeline on those forever provides no signal.- GitLab Container Registry cleanup policies can report
status: :successwhile deleting nothing, on self-managed instances without the registry metadata database — success status alone doesn't confirm actual cleanup; checkdeleted_sizespecifically. - A "keep only N tags" cleanup policy is a real trade-off against rollback depth, not just a disk-space optimization — worth deciding deliberately, not defaulting to the most aggressive setting.
git add -Aafter running any local tool that writes into the working directory (likedocker run -v $(pwd):/app npm update) can silently stage hundreds of vendored dependency files if.gitignoreisn't already in place — worth adding.gitignorebefore the first commit on any new project, not after the fact.





