- Shell 58.6%
- Python 11%
- Go Template 7.8%
- HCL 7.5%
- C# 4.6%
- Other 10.5%
One project, three build configurations (Release_ClientA/B/C), three SemVer lines. Each client has a GitVersion config whose ignore.paths hides every commit that did not touch that client's inputs; scoped Conventional Commits decide how much to bump; tags are client-a/vX.Y.Z. - build/versioning/generate-paths.sh asks MSBuild (-getItem) what each configuration compiles and ships, collapses it to folders and generates GitVersion.<client>.yml, rules:changes for CI and the release path list. check-generated fails the pipeline when they are stale. - .gitlab-ci.yml: build:<client> with rules:changes from the generated list, version rewritten into Clients/<X>/AssemblyInfo.cs, dotenv artifact; manual release:<client> tags the newest commit touching the client's paths (a tag on an ignored commit is invisible to GitVersion); tag pipelines build the release artifact and refuse a version/tag mismatch. - verify-scenarios.sh replays 8 scenarios on a throwaway repo (10/10 with GitVersion.Tool 6.8.2, pinned in .config/dotnet-tools.json). - README lists the measured gotchas: invisible tags, non-accumulating bumps, same-second commits, locale-dependent sort, #if in shared code, CS0579. Verified on the local GitLab 19.4 lab: rules:changes, manual release, tag pipeline with the version baked into the DLL. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|---|---|---|
| dependency-track | ||
| docs | ||
| exercises | ||
| feature-flags | ||
| features | ||
| gitlab-agent | ||
| gitops-capstone | ||
| harbor | ||
| hetzner-workshop | ||
| k8s-runner | ||
| monitoring | ||
| review-apps | ||
| runner-config | ||
| sample-apps | ||
| scripts | ||
| terraform | ||
| vault | ||
| wireguard | ||
| workshop | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| README.md | ||
GitLab CE local playground
A self-contained GitLab CE + GitLab Runner lab for learning, running on Docker.
Works on macOS (Docker Desktop) and Linux (Docker Engine), on amd64 and
arm64. On Ubuntu, read docs/UBUNTU.md first — it covers the
host setup that differs (apt repo, kernel limits, firewall, file ownership).
What's here
| File | Purpose |
|---|---|
docker-compose.yml |
GitLab CE + one GitLab Runner (Docker executor) |
.env |
Ports, hostname, root password, bind address — copy from .env.example |
scripts/preflight.sh |
Read-only host check (Docker, RAM, ports, kernel limits) |
scripts/health-check.sh |
Read-only health check across every stack (GitLab, runner, Harbor, Dependency-Track, Vault, monitoring, kind) |
docs/UBUNTU.md |
Host setup + Linux-specific behaviour |
scripts/register-runner.sh |
Registers the runner with one command (applies runner-config/runner-template.toml) |
runner-config/ |
The runner's /etc/gitlab-runner, bind-mounted: concurrent, docker socket for jobs |
scripts/seed-groups.sh |
Seeds a group/subgroup hierarchy + 4 users with different roles (see §16) |
scripts/security-audit.sh |
Read-only security posture review of the instance (see §17) |
harbor/ |
Harbor container registry — its own compose stack (see §5) |
dependency-track/ |
OWASP Dependency-Track (SBOM monitoring) — its own compose stack (see §6) |
vault/ |
HashiCorp Vault (secrets via OIDC) — its own compose stack (see §7) |
k8s-runner/ |
GitLab Runner on a kind Kubernetes cluster (see §8) |
monitoring/ |
Prometheus + Grafana dashboards for runner/CI metrics (see §9) |
terraform/ |
IaC pipeline with GitLab-managed state (see §10) |
feature-flags/ |
Flag-gated pipeline using GitLab's built-in Unleash API |
wireguard/ |
WireGuard VPN + an isolated service, for the CI-over-VPN exercise (see §15) |
gitlab-agent/ |
GitLab Agent for Kubernetes (agentk ↔ KAS) — cluster connection (see §11) |
review-apps/ |
Per-MR ephemeral environments on kind + OWASP ZAP DAST (see §12) |
gitops-capstone/ |
🏆 build→SAST→SBOM→sign→push→ArgoCD deploy, end-to-end (see §13) |
features/ |
24 CE feature showcases: registries/packages, CI automation, reporting, Flux, auth, governance… (see §14) |
workshop/ |
GitLab CI/CD workshop: 36 modules — pipelines, runners, security, automation, plus a self-managed GitLab install/upgrade/restore drill |
sample-apps/ |
Deliberately vulnerable apps + a SAST/SCA/SBOM/DAST pipeline; setup.sh publishes them as a security-demo group |
Data lives in Docker named volumes (gitlab_config, gitlab_data, gitlab_logs), so it survives docker compose down and restarts.
The runner is the exception: its /etc/gitlab-runner is bind-mounted from
runner-config/ in this repo, so its settings are versioned and
survive even docker compose down -v. config.toml itself is gitignored because
gitlab-runner register writes runner auth tokens into it; what is committed is
config.toml.example (globals, concurrent = 4) and runner-template.toml
(per-runner docker settings, including the host Docker socket that lets jobs build
and push images).
1. Start it
cp .env.example .env # first time only; edit the root password
./scripts/preflight.sh # optional: checks Docker, RAM, free ports
docker compose up -d
First boot takes 5–8 minutes (GitLab reconfigures itself). Watch progress:
docker compose logs -f gitlab # Ctrl-C to stop following
docker compose ps # wait until gitlab is "healthy"
2. Log in
- Open http://localhost:8929
- User:
root - Password: value of
GITLAB_ROOT_PASSWORDin.env(defaultChangeMe_local123)
Change the password after first login. The
initial_root_passwordonly applies on the very first boot with an empty data volume.
3. Register a runner
GitLab 16+ registers runners with an authentication token (glrt-...) created in the UI:
- In GitLab: Admin Area → CI/CD → Runners → New instance runner
- Pick a tag or check "Run untagged jobs" so your first pipeline is picked up.
- Click Create runner, copy the
glrt-...token.
- Register it:
./scripts/register-runner.sh glrt-xxxxxxxxxxxxxxxxxxxx
- Verify:
docker compose exec runner gitlab-runner list
The runner should now show as online (green) in the Runners page.
4. Try a pipeline
Create a new project in the UI, then add this .gitlab-ci.yml at the repo root:
stages: [build, test]
build-job:
stage: build
image: alpine:latest
script:
- echo "Building..."
- echo "hello from CI" > artifact.txt
artifacts:
paths: [artifact.txt]
test-job:
stage: test
image: alpine:latest
script:
- echo "Testing..."
- cat artifact.txt
- test -f artifact.txt && echo "OK"
Commit it — the pipeline runs under Build → Pipelines.
Pushing code via SSH
SSH is mapped to host port 2222:
git remote add origin ssh://git@localhost:2222/root/my-project.git
# add your SSH key in GitLab: User Settings -> SSH Keys
Or over HTTP:
git remote add origin http://localhost:8929/root/my-project.git
5. Harbor container registry
Harbor is a separate compose stack under harbor/ (it's a ~9-container app
distributed via its own installer, so it can't be merged into the GitLab file).
cd harbor && ./setup-lab.sh # renders harbor.yml for this checkout, then starts it
setup-lab.sh does three things Harbor's own installer can't: it renders
harbor.yml for this checkout (data_volume must be an absolute path, so the
committed source is harbor.yml.lab.tmpl), bounces the proxy after ./prepare
so it re-resolves recreated backends, and joins the proxy to the GitLab lab
network as harbor — that alias is what makes http://harbor:8080 work from
CI jobs, the kind node and Terraform. Once up it's reachable at:
- Web UI: http://localhost:8090
- User:
admin - Password:
Harbor12345(set inharbor/harbor.ymlasharbor_admin_password)
Push / pull from the host
localhost registries are treated as insecure-OK by Docker, so plain HTTP just works:
docker login localhost:8090 -u admin -p Harbor12345
docker tag alpine:latest localhost:8090/library/alpine:lab
docker push localhost:8090/library/alpine:lab
(library is Harbor's built-in public project; create your own projects in the UI.)
Push from a GitLab CI pipeline
The runner shares the host Docker socket with job containers, so a job's docker
CLI talks to the host daemon — where localhost:8090 reaches Harbor, even
though Harbor sits on its own docker network. That wiring comes from
runner-config/runner-template.toml and is applied at registration time, so a
re-registered runner keeps it. Verified end to end: a job built an image and
pushed localhost:8090/library/socket-test to Harbor.
Example .gitlab-ci.yml:
build-image:
stage: build
image: docker:27
variables:
HARBOR: localhost:8090
DOCKER_HOST: unix:///var/run/docker.sock # use the mounted host socket
script:
- echo "Harbor12345" | docker login $HARBOR -u admin --password-stdin
- |
cat > Dockerfile <<'EOF'
FROM alpine:latest
RUN echo "built in CI" > /hello.txt
EOF
- docker build -t $HARBOR/library/ci-demo:$CI_COMMIT_SHORT_SHA .
- docker push $HARBOR/library/ci-demo:$CI_COMMIT_SHORT_SHA
For real projects use a Harbor robot account instead of the admin password, and store it in a masked CI/CD variable rather than inline.
Managing the Harbor stack
cd harbor
docker compose ps # status of the 9 Harbor containers
docker compose stop # stop Harbor (keeps data)
docker compose up -d # start Harbor
docker compose down # remove containers (data survives in harbor/data)
docker compose down -v && rm -rf data # full reset
After editing harbor/harbor.yml, regenerate the compose: cd harbor && ./prepare && docker compose up -d.
Harbor gotchas
- amd64 emulation (Apple Silicon only): Harbor ships amd64-only images; on
Apple Silicon they run under emulation (works, just slower) and the
platform ... does not matchwarnings are expected. On amd64 Linux they run natively — no warnings, noticeably faster. - First
upmay fail one container with a virtiofs single-file-mount error — Docker Desktop only; just rundocker compose up -dagain (it's intermittent). - Root-owned files (Linux only):
prepareand the containers write as root, so a reset needssudo rm -rf harbor/data harbor/common.
6. Dependency-Track (SBOM monitoring)
OWASP Dependency-Track ingests CycloneDX SBOMs and continuously tracks the
components for new CVEs. It's a separate compose stack under
dependency-track/, joined to the GitLab network so CI jobs can upload SBOMs.
- Web UI: http://localhost:8092 — login admin / dtrackAdmin!23
- API (host): http://localhost:8091 · from CI jobs:
http://dtrack-apiserver:8080
cd dependency-track
docker compose up -d # apiserver takes ~2-3 min on first boot
docker compose ps
docker compose down # stop (data persists in the dtrack_data volume)
Already wired for you: an Automation API key with upload permissions is stored
as the masked GitLab instance CI/CD variable DTRACK_API_KEY, so workshop
modules 16 & 19 upload SBOMs with no extra setup. A demo project workshop-demo
(106 components) already exists proving the flow. Full details + the CycloneDX
version-compatibility note are in workshop/SECURITY.md.
Multi-arch images (apiserver + frontend) — native on amd64 and arm64.
7. HashiCorp Vault (secrets via OIDC)
Vault (dev mode, multi-arch image) in vault/, joined to the GitLab network. CI jobs
fetch secrets with no stored Vault credentials — they exchange a short-lived
GitLab OIDC JWT (id_tokens) for a scoped Vault token.
- Web UI / API: http://localhost:8200 — root token
root-token-lab - From CI jobs:
http://vault:8200
cd vault
docker compose up -d # vault + one-shot init that wires JWT auth to GitLab
docker compose logs vault-init
docker compose down # stop (dev mode: secrets reset, re-seeded on next up)
Pre-wired & verified: JWT auth trusting GitLab, role gitlab-ci
(aud=vault.local, namespace_path=root, policy ci-read), and secret
secret/ci/myapp. A real pipeline (workshop module 20) logged in via OIDC and
read the secret end-to-end. Per-project isolation (module 21) is also wired
and verified: a single templated policy scopes each project to
secret/ci/<its-path>/* (roles gitlab-ci-isolated and, for protected refs,
gitlab-ci-prod). Details in workshop/SECURITY.md.
Runner note: this lab's runner has
clone_url=http://gitlab:8929set so job containers can clone (GitLab'slocalhost:8929external URL isn't reachable from inside a container). The register scripts apply this automatically.
8. GitLab Runner on Kubernetes (kind)
A 1-node kind cluster running the GitLab Runner
Kubernetes executor — every CI job becomes a pod. Lives in k8s-runner/.
cd k8s-runner
./setup.sh glrt-XXXX # create a runner in GitLab (tags: kubernetes,kind), pass its token
kubectl -n gitlab-runner get pods -w # watch job pods appear when a tagged job runs
./teardown.sh # remove the cluster + runner
Jobs route to it via tags: [kubernetes] (see workshop/24-kubernetes-executor.yml).
Verified end-to-end: a pipeline ran 5 jobs as pods (incl. parallel:3 → 3 pods and
a Postgres sidecar). The README documents the four gotchas solved (cluster↔GitLab
networking, clone_url, helper-image architecture, and unregisterRunners: false).
The kind node is joined to the
gitlab-ce-lab_defaultDocker network so pods resolvegitlab:8929. All images are multi-arch, andsetup.shpicks the matching runner-helper build fromuname -m.
9. Monitoring (Prometheus + Grafana)
Runner/CI metrics dashboards in monitoring/, joined to the GitLab network so
Prometheus scrapes the runner's metrics endpoint (gitlab-runner:9252).
- Grafana: http://localhost:3000 — admin / grafanaLab123 (anonymous view on)
- Prometheus: http://localhost:9090
cd monitoring && docker compose up -d
Open the provisioned "GitLab Runner / CI" dashboard: jobs processed, jobs/min,
job duration p50/p95, jobs by stage, concurrency, API errors. The runner exposes
metrics because its config.toml has listen_address=":9252". GitLab app metrics
are off by default (resources) — the README shows the one-block opt-in. Verified:
after running pipelines, sum(gitlab_runner_jobs_total) reflected real job counts.
10. Terraform with GitLab-managed state
terraform/ — an IaC pipeline that provisions a Harbor project with Terraform,
storing state in GitLab's managed Terraform HTTP backend (CE feature, no S3).
Published to the root/terraform-harbor project: validate → plan (shown on MR) → apply (manual). Harbor is reached at http://harbor:8080 (its proxy is aliased
onto the GitLab network). Verified: apply created the Harbor project
tf-managed; state is visible under Operate → Terraform states. See terraform/README.md.
CI/CD Catalog component
The source for a CI/CD Catalog component now lives with the module that teaches
it: workshop/13-components/component/. Publish it as root/ci-components,
release it as trivy-scan@1.0.0, and consume it with
include: - component: $CI_SERVER_FQDN/root/ci-components/trivy-scan@1.0.0 —
step by step in workshop/13-components/README.md.
Verified by a consumer pipeline that ran the component's trivy-scan job.
11. GitLab Agent for Kubernetes
gitlab-agent/ connects the kind cluster to GitLab the official way: an agentk
pod holds a persistent link to GitLab's KAS, so the cluster is managed from
GitLab (Operate → Kubernetes clusters) and deployments show in the Environments UI.
cd gitlab-agent
./setup.sh <agent-token> # helm-install agentk into kind, pointed at KAS
Verified CONNECTED (GraphQL reports active connections, agent v19.1.0). The
CI/CD tunnel (kubectl-from-CI with no stored kubeconfig) does not complete on
this lab — KAS binds the tunnel token to GitLab's canonical localhost host, the
same external_url theme seen elsewhere. gitlab-agent/README.md documents the
root cause and the clean fix (a both-sides-reachable hostname). For CI-driven
deploys the capstone uses a direct kubeconfig to the kind API instead.
12. Review Apps → kind + DAST
review-apps/ gives every merge request an ephemeral environment: the app is
deployed into its own namespace in the kind cluster, OWASP ZAP scans the live
URL, and the namespace is torn down when the MR is merged/closed. Project
root/review-apps-demo.
Because the Agent tunnel can't complete here (§11), deploy jobs use a direct
kubeconfig (file-type CI/CD variable KUBECONFIG_CI) authenticating as a
least-privilege SA ci-deployer against https://gitlab-ci-control-plane:6443.
The scanner reaches the app via a per-MR NodePort on the kind node. Verified:
MR !1 deployed to ns review-mr-1, ZAP produced a 10-finding report, the
environment showed the live URL, and stop_review deleted the namespace (env →
stopped). See review-apps/README.md.
13. 🏆 GitOps capstone
gitops-capstone/ is the showcase that ties everything together. One git push
runs a full secure-supply-chain pipeline — test → SAST (Semgrep) → build & push to
Harbor → SBOM (Trivy → Dependency-Track) → sign (cosign) → bump the k8s manifest —
and then ArgoCD (running in the kind cluster) pulls the change and deploys it.
Project root/gitops-capstone.
CI never runs kubectl: it produces a signed image + desired state in git;
ArgoCD converges the cluster. Verified end-to-end — a commit flowed all the way
to 2/2 running pods on the exact signed image, and a second commit rolled the live
version automatically (65ab3210 → 47cd8322). This exercises Harbor, Dependency-
Track, cosign, kind, and ArgoCD at once. Full write-up (incl. the containerd
insecure-registry and cosign-over-HTTP details) in gitops-capstone/README.md.
14. Feature showcases (features/)
18 CE features, each with a verified demo. Full index + commands in
features/README.md. By theme:
Registry & packages — built-in Container Registry (now enabled, port 5050), npm, Helm, Dependency Proxy (Docker Hub cache), Terraform Module Registry, Generic + PyPI packages, and a registry cleanup policy.
Automation & ops — pipeline schedules, trigger tokens, resource_group
interruptible/auto-cancel, project webhooks → a sink, alerts → incidents- error tracking, and Auto DevOps (one toggle → build+scan pipeline).
Runners & GitOps — Flux pull-GitOps (GitLab's recommended integration; verified a git change auto-reconciling to the cluster) and group runners + autoscaling notes.
CI reporting & quality — JUnit test reports, coverage visualization + badge, Code Quality (hadolint), and Accessibility (pa11y) — all parsed by GitLab.
Ops governance & supply chain — CI/CD Secure Files, deploy freeze
periods, OIDC id_token federation (verified against GitLab's JWKS), and
GitLab self-monitoring metrics (the gitlab Prometheus target is now up with a
"GitLab / Application" Grafana dashboard — see §9).
Platform & auth — deploy tokens & keys, project/group/personal access
tokens, gitlab-backup, and push mirroring.
All CI auth is the job token (no stored creds). The recurring localhost-in-
returned-URLs theme is handled per-tool (socat shim for npm/Helm/PyPI; host Docker
daemon for the registry/proxy; raw protocol for Terraform; gitlab:8929 for
mirroring) — see features/README.md.
Enabling the container registry required a one-time GitLab reconfigure (already done). A first boot after
registry['enable'] = truetakes a few extra minutes.
15. WireGuard VPN (CI into a private network)
wireguard/ runs a VPN gateway plus an isolated nginx (10.20.0.10) on a
docker network nothing else joins — there is no route to it from the lab, so the
tunnel is the only way in. A CI job dials it with wg-quick, the real shape of
deploying to a network that isn't on the internet.
cd wireguard && ./setup.sh # generates keys, starts the gateway, prints the CI variables
The job needs NET_ADMIN + /dev/net/tun, which are runner settings — so the
capability lives on a dedicated vpn-tagged runner rather than on the runner that
executes every merge request. Verified: the tunnel job fetched the private app
while the same job on a runner without the capability failed at
RTNETLINK answers: Operation not permitted. Walkthrough in
exercises/22-vpn-wireguard.md; details in wireguard/README.md.
16. Groups, subgroups & permissions
Everything else in this lab lives under the single root user, which hides the
half of GitLab that is a hierarchy: membership, roles, CI/CD variables, runners
and milestones all flow downhill from a group to the projects inside it.
./scripts/seed-groups.sh <admin-token> # build it
./scripts/seed-groups.sh <admin-token> --reset # tear it down
Creates acme (top-level) with subgroups platform and apps, three projects and
four users — alice (Owner), bob (Maintainer of platform), carol (Developer in
apps), dave (Guest on acme). Nobody is added to a project; access is
inherited. Verified: bob gets 404 on apps/web while carol gets 200, and
vice-versa for platform/shared-ci — GitLab returns not found, never forbidden,
for things you can't see. The same pipeline in two sibling projects resolves
DEPLOY_TARGET differently (from-project-web vs from-subgroup-apps), proving
precedence is project → subgroup → group → instance.
"Organizations" exist as a model in 19.1 (one row,
Default) but are pre-release cells-architecture plumbing: no REST API, UI behind a disabled feature flag. In CE today the top-level group is the organization.
Details in features/groups-and-permissions/README.md; hands-on in
exercises/25-groups-and-permissions.md.
17. Managing security (instance posture)
The lab covers application security thoroughly (§ the security modules in
workshop/SECURITY.md). This is the other half: securing GitLab itself.
./scripts/security-audit.sh <admin-token> # read-only; changes nothing
Audits sign-up, 2FA, sessions, token policy, project visibility, CI job-token scope, runner exposure, webhook SSRF and rate limits, printing the fix for each finding. On this lab it reports roughly 9 pass, 17 warn, 1 fail — and several of those warnings are deliberate (webhooks must reach local addresses for the webhook demo; runners mount the Docker socket so exercise 08 can build images). Knowing which doors are open, and why, is the point.
features/instance-security/README.md explains every finding, maps which controls
exist in CE versus Premium (audit events API: 404 in CE), and lists the
incident-response one-liners for revoking tokens and blocking accounts.
Everyday commands
docker compose up -d # start
docker compose stop # stop (keeps data)
docker compose down # remove containers (keeps named volumes)
docker compose down -v # nuke EVERYTHING including data
docker compose logs -f gitlab # tail GitLab logs
docker compose exec gitlab gitlab-ctl status # internal service status
docker compose exec gitlab gitlab-rake gitlab:check # health check
Notes / gotchas
- Memory: GitLab wants ~4 GB. On macOS give Docker Desktop 6–8 GB; on Linux containers use host RAM directly, so just keep 8 GB+ free.
- Runner networking: the runner reaches GitLab via the compose service name
gitlabon the internal network — that's whyregister-runner.shuseshttp://gitlab:8929rather thanlocalhost. Job containers join the samegitlab-ce-lab_defaultnetwork. - Docker socket: the runner uses socket-binding to your host Docker. Fine for
a local lab; don't do this on a shared/production host. Assumes rootful
Docker — see
docs/UBUNTU.mdif you run rootless. - Port binding:
LAB_BIND_IPin.env(default127.0.0.1) controls which interface the stacks publish on. On Linux, Docker's iptables rules bypassufw, so0.0.0.0really does expose these default-password services to the network. - Architecture: amd64 and arm64 both work — the arch-specific downloads
(cosign, hadolint, the runner helper image) resolve from
uname -m.