From GitHub to GitLab: CI/CD Independence for a European Platform
The Challenge
There is something awkward about building a European digital sovereignty platform on Microsoft-owned infrastructure. GitHub Actions is convenient — deeply integrated, well-documented, generous free tier — but every workflow run happens on Azure machines in regions Microsoft chooses. Your source code, build logs, and deployment secrets all flow through American infrastructure.
For Clouds of Europe, this contradiction was the problem. We needed our entire CI/CD pipeline — security scanning, container builds, infrastructure provisioning, and deployment — running on European infrastructure we control.
The specific concerns:
- Jurisdiction clarity. GDPR applies to our infrastructure. American subpoenas do not. "Our CI/CD runs on Microsoft Azure" is a compliance conversation you do not want to have with a German regulator.
- Secret exposure. GitHub Actions secrets are stored on GitHub's infrastructure. Registry credentials, deployment keys, SOPS encryption keys — all on US servers.
- Build artifact residency. Container images built on GitHub Actions pass through Azure infrastructure before reaching their final registry. For a sovereignty platform, this is a data flow you cannot justify.
The Approach
We chose GitLab over lighter alternatives (Gitea, Forgejo) for one reason: CI/CD maturity. GitLab's pipeline system has evolved for over a decade. The runner ecosystem is stable. The documentation is comprehensive. And critically, you can self-host it on infrastructure you control.
We run our GitLab instance on European infrastructure managed by Aknostic. The runners execute on European servers. Build artifacts stay in European storage.
Pipeline Architecture
The pipeline has three stages, each in its own file under .gitlab/ci/:
stages:
- security
- build
- deployWe added pipeline inputs — dropdown menus in GitLab's "Run Pipeline" UI — for operational flexibility:
spec:
inputs:
start_with:
description: "Stage to start from (skip earlier stages)"
default: "security"
options:
- "security"
- "build"
- "deploy"
deploy_env:
description: "Environment to deploy"
default: "test"
options:
- "test"
- "production"This turned out to be more valuable than expected. When you need to re-deploy production without rebuilding — Flux got stuck, or you are testing deployment scripts — you select start_with: deploy and deploy_env: production. No waiting for security scans and Docker builds you do not need.
The Solution
Security Scanning: Build Your Own
GitHub Actions has a marketplace of security scanning actions. GitLab has less of that. But building your own security stage is not hard, and the result is more transparent.
TruffleHog Secret Scanning with --only-verified: TruffleHog detects patterns that look like secrets, but many are false positives — example configurations, test fixtures, documentation. The --only-verified flag tells TruffleHog to actually test credentials against their services and only fail if they are real and active. Dramatically more useful than wading through hundreds of "this looks like it might be a secret" warnings.
trufflehog-scan:
stage: security
image: trufflesecurity/trufflehog:latest
variables:
GIT_DEPTH: 0
script:
- |
if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then
BASE_SHA="$CI_MERGE_REQUEST_TARGET_BRANCH_SHA"
elif [ -n "$CI_COMMIT_BEFORE_SHA" ] && [ "$CI_COMMIT_BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then
BASE_SHA="$CI_COMMIT_BEFORE_SHA"
else
BASE_SHA="HEAD~10"
fi
trufflehog git file://. --since-commit="$BASE_SHA" --only-verified --fail --no-updateHardcoded Secrets Check for codebase-specific anti-patterns: plain text appPassword in YAML files, .plain.yaml files that someone forgot to encrypt before committing. Generic tools miss these. Custom checks catch them.
SOPS Validation ensures encrypted secrets are actually encrypted. Files in sops-secrets/ directories must contain ENC[AES markers. We learned the hard way to exclude kustomization.yaml — configuration files in secrets directories are not secrets themselves.
The Docker Build Detour
GitHub Actions runners have Docker available by default. GitLab runners — it is complicated.
We tried Kaniko first — Google's tool for building container images without Docker. No daemon, no privileged mode. The security argument is sound: privileged runners let malicious jobs potentially escape the container.
The reality: Kaniko's authentication handling was awkward. The config file needs to be in /kaniko/.docker/, which is read-only in the executor image. Registry authentication that works with docker login required careful base64 encoding. We spent a day fighting Kaniko's quirks.
Then we stepped back. Our GitLab runners are dedicated to our projects. We control what jobs run on them. The privileged mode concern is real for shared runners with untrusted code — but that is not our situation.
We reverted to Docker-in-Docker:
build-and-push:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
before_script:
- echo "$SCW_SECRET_KEY" | docker login $REGISTRY -u nologin --password-stdinDOCKER_TLS_CERTDIR: "" disables TLS between the build job and the DinD sidecar. In a Kubernetes pod's localhost, TLS adds complexity without meaningful security benefit.
The lesson: sometimes the straightforward solution is the right one. Kaniko solves a real problem, but if you control your runners, the added complexity is not worth it.
Deployment: OpenTofu and Flux
The deploy stage provisions infrastructure with OpenTofu and deploys applications through Flux GitOps:
deploy-test:
stage: deploy
environment:
name: test
url: https://test.clouds-of-europe.eu
script:
- |
cd infrastructure/opentofu/environments/test
tofu init -upgrade \
-backend-config="bucket=coe-opentofu-state" \
-backend-config="key=test/terraform.tfstate" \
-backend-config="region=fr-par" \
-backend-config="endpoint=https://s3.fr-par.scw.cloud"
tofu plan -out=tfplan
tofu apply -auto-approve tfplan
tofu output -raw kubeconfig > /tmp/kubeconfig.yaml
export KUBECONFIG=/tmp/kubeconfig.yaml
if kubectl get deployment source-controller -n flux-system &>/dev/null; then
flux reconcile source git flux-system -n flux-system
else
flux bootstrap gitlab \
--hostname=gitlab.aknostic.com \
--owner=clouds-of-europe \
--repository=clouds-of-europe \
--branch=main \
--path=gitops/clusters/test \
--token-auth
fiKey decisions:
- OpenTofu over Terraform. The open-source fork, created after HashiCorp's license change. For a sovereignty platform, truly open-source infrastructure tooling is non-negotiable.
- Kubeconfig as pipeline artifact with 7-day expiration. Cluster access available from the GitLab UI for debugging and emergency access, without credentials persisting indefinitely.
- Flux idempotence. The script checks whether Flux is already installed before bootstrapping. Run the job ten times, same result.
- Environment-specific SOPS keys.
SOPS_AGE_KEY_TESTandSOPS_AGE_KEY_PRODUCTIONare separate. Each environment can only decrypt its own secrets.
Production deployment requires explicit opt-in but is automatic when you have already decided:
deploy-production:
rules:
- if: $CI_PIPELINE_SOURCE == "web" && $DEPLOY_ENV == "production"
- if: $CI_PIPELINE_SOURCE == "web"
when: manualSelect deploy_env: production from the UI, deployment starts immediately. Normal pipeline without that selection requires a manual click. Safety without unnecessary confirmation dialogs.
Outcomes
| Aspect | GitHub Actions | Self-Hosted GitLab | |--------|---------------|-------------------| | Source code jurisdiction | US (Microsoft) | European (Aknostic-managed) | | Build execution | Azure (Microsoft-chosen region) | European servers (our choice) | | Secret storage | GitHub infrastructure | GitLab on European infrastructure | | Container registry | Via Azure, then target registry | Direct to Scaleway Paris | | Security scanning | Marketplace actions (opaque) | Custom pipeline (transparent) | | Docker builds | Native (convenient) | DinD (controlled) |
What we gained:
- Clearer stage separation. A build failure does not require re-running security scans. A deployment re-run does not require rebuilding.
- Transparent security. Instead of trusting a marketplace action, we see exactly what TruffleHog and our custom checks do. When something fails, the debug path is clear.
- Workflow flexibility. Pipeline inputs let operators skip stages and target environments without editing pipeline code.
What we lost — honestly:
- GitHub Actions' ecosystem. The marketplace has thousands of actions. GitLab's ecosystem is smaller. We wrote more shell scripts.
- Community documentation. Stack Overflow has more GitHub Actions answers. When something breaks, you are more likely to find someone who has seen it before.
- Integration convenience. GitHub Actions workflows can reference other repositories, use GitHub's secrets management, trigger on GitHub events. We had to build some of this ourselves.
For Clouds of Europe, independence outweighed these inconveniences. For projects without regulatory requirements or philosophical commitments to European infrastructure, GitHub Actions might be the better choice. The point is not that GitLab is universally superior — it is that migration is achievable when independence matters.
Lessons Learned
-
Self-hosted CI/CD is achievable. GitLab's runner model is mature. The pipeline syntax is well-documented. You can migrate from GitHub Actions without heroic effort.
-
Build your own security scanning. TruffleHog with
--only-verifiedis more useful than marketplace actions that generate noise. Custom checks for your codebase's specific anti-patterns catch what generic tools miss. -
Question your assumptions about privileged runners. Kaniko solves a real problem, but if you control your runners, Docker-in-Docker with proper configuration might be simpler.
-
Use pipeline inputs for workflow flexibility. The ability to skip stages and select deployment targets without editing code makes operations smoother.
-
Save kubeconfigs as artifacts. Having cluster access available from the pipeline UI is valuable for debugging and emergency access.