Kubernetes infrastructure on Scaleway using OpenTofu and Flux GitOps.
What you get: A production-ready Kubernetes cluster with private nodes, GitOps-based deployments via Flux, automatic DNS record management, TLS certificate provisioning, and an Envoy-based ingress gateway with a static public IP.
- Scaleway account with Organization access
- GitHub repository with fine-grained PAT (Contents + Administration)
- CLI tools:
scw,tofu,flux,kubectl,sops,age,gh
First, ensure you have the Scaleway CLI configured with organization-level access:
# Initial CLI setup (if not already done)
scw init
# Use your organization-level API key from console.scaleway.comCreate a dedicated Scaleway project for resource isolation:
# Create new project (or use existing project ID)
scw account project create name=myproject
# Note the project ID from output
# Create IAM application for this project
scw iam application create name=myproject-infra
# Create API key with project scope
scw iam api-key create application-id=<app-id> default-project-id=<project-id>
# Note the access_key and secret_key from output
# Configure CLI with the new key
scw init
# Enter the access_key and secret_key when promptedImportant: The API key's default_project_id determines where resources are created. S3 buckets inherit project scope from the API key, not from bucket creation parameters.
# Create your repo from the template
gh repo create myorg/myproject-infra --template __GITHUB_ORG__/scaleway-project-scaffold --public --clone
cd myproject-infra
# Get latest K8s version
scw k8s version list -o json | jq -r '.[0].name'Replace all placeholders (the workflow will fail if any remain):
| Placeholder | Description | Example |
|---|---|---|
__PROJECT_NAME__ |
Your project identifier | myproject |
__DOMAIN__ |
Your DNS domain | myproject.com |
__GITHUB_ORG__ |
GitHub org or username | myorg |
__GITHUB_REPO__ |
Repository name | myproject-infra |
__K8S_VERSION__ |
Kubernetes version (from above) | 1.32 |
# Replace placeholders (macOS)
find . -type f \( -name "*.yaml" -o -name "*.tf" -o -name "*.yml" -o -name "*.md" \) | xargs sed -i '' 's/__PROJECT_NAME__/myproject/g'
find . -type f \( -name "*.yaml" -o -name "*.tf" -o -name "*.yml" -o -name "*.md" \) | xargs sed -i '' 's/__DOMAIN__/myproject.com/g'
find . -type f \( -name "*.yaml" -o -name "*.tf" -o -name "*.yml" -o -name "*.md" \) | xargs sed -i '' 's/__GITHUB_ORG__/myorg/g'
find . -type f \( -name "*.yaml" -o -name "*.tf" -o -name "*.yml" -o -name "*.md" \) | xargs sed -i '' 's/__GITHUB_REPO__/myproject-infra/g'
find . -type f \( -name "*.yaml" -o -name "*.tf" -o -name "*.yml" -o -name "*.md" \) | xargs sed -i '' 's/__K8S_VERSION__/1.32/g'
# Linux: use sed -i instead of sed -i ''
# Verify all placeholders replaced (should return nothing)
grep -r "__[A-Z_]*__" --include="*.tf" --include="*.yaml" --include="*.yml" .age-keygen -o age.key
# Note the public key (age1...) from output
# Update .sops.yaml with your public key (this placeholder is separate from Step 2)
sed -i '' 's/__SOPS_AGE_PUBLIC_KEY__/age1yourpublickeyhere/g' .sops.yaml| Placeholder | Description |
|---|---|
__SOPS_AGE_PUBLIC_KEY__ |
Your age public key (starts with age1...) |
scw object bucket create name=myproject-tfstate region=fr-parTwo secrets need your Scaleway credentials (same values for both):
# Edit external-dns credentials
vi gitops/infrastructure/core/external-dns-secret.yaml
# Edit cert-manager credentials (for DNS-01 certificate validation)
vi gitops/infrastructure/core/scaleway-credentials-certmanager.yaml
# Encrypt both with SOPS
sops --encrypt --in-place gitops/infrastructure/core/external-dns-secret.yaml
sops --encrypt --in-place gitops/infrastructure/core/scaleway-credentials-certmanager.yamlUse the project-scoped API key created in step 1:
gh secret set SCW_ACCESS_KEY --body "SCWXXXXXXXXX"
gh secret set SCW_SECRET_KEY --body "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
gh secret set SCW_DEFAULT_ORGANIZATION_ID --body "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
gh secret set SCW_DEFAULT_PROJECT_ID --body "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
gh secret set FLUX_GITHUB_TOKEN --body "github_pat_..."
gh secret set SOPS_AGE_KEY < age.keyCreate a "production" environment for the deployment approval gate:
- Go to repository Settings > Environments
- Click "New environment"
- Name it
production - Optionally configure required reviewers for manual approval
Or skip approval by using skip_approval: true when triggering the workflow.
git add .
git commit -m "Configure project"
git push
gh workflow run infrastructure.yml -f action=deploy- Download kubeconfig:
- From workflow run artifacts, or
- From Scaleway Console (Kubernetes → Cluster → Download kubeconfig) if you have console access
- Verify Flux:
flux get kustomizations - Verify ClusterIssuer:
kubectl get clusterissuer letsencrypt-prod - Add HTTPRoutes to expose services (certificates are issued automatically via DNS-01)
Use the node_pools variable in infrastructure/opentofu/environments/main/variables.tf:
variable "node_pools" {
default = [
{ name = "pool-1", zone = "fr-par-1", size = 1 },
{ name = "pool-2", zone = "fr-par-2", size = 1 }
]
}Each pool supports: name, zone, size, min_size, max_size, node_type.
Add Grafana, Prometheus, Loki stacks to gitops/infrastructure/observability/.
Copy infrastructure/opentofu/environments/main/ to create staging/prod environments.
If you see errors about __PROJECT_NAME__ or similar, placeholders weren't fully replaced. Verify:
# Check for remaining placeholders
grep -r "__[A-Z_]*__" --include="*.tf" --include="*.yaml" --include="*.yml" .Project-scoped API keys need specific permissions. If you see insufficient permissions errors:
| Error | Required Permission |
|---|---|
write application |
IAMManager (for creating per-service IAM) |
read project |
ProjectReadOnly (for data source lookups) |
Simplest fix: Use a single shared API key with full project access rather than per-service IAM applications.
Not all instance types are available in all zones. Common issue:
Error: commercial_type does not respect constraint, commercial type not available in this zone
Safe defaults: fr-par-1 and fr-par-2 have the widest instance availability. Avoid fr-par-3 for DEV1/PRO2 types unless you verify availability first:
scw instance server-type list zone=fr-par-1If you see errors like no matches for kind "ClusterIssuer", the CRD isn't installed yet. Ensure resources that depend on CRDs (like ClusterIssuer) are in a Kustomization that dependsOn the operator installation (cert-manager).
This scaffold prioritizes European digital sovereignty using EU-owned infrastructure and open source tools.
| Component | Provider | Governance |
|---|---|---|
| Cloud | Scaleway | French (Iliad Group), EU data centers |
| IaC | OpenTofu | Linux Foundation, MPL 2.0 |
| GitOps | Flux CD | CNCF Graduated |
| Ingress | Envoy Gateway | CNCF Graduated |
| Certificates | cert-manager | CNCF Graduated |
| Secrets | SOPS/age | Mozilla (SOPS), Filippo Valsorda (age) |
| State | Scaleway Object Storage | French, fr-par region |
- GitHub/GitHub Actions - US-owned, subject to CLOUD Act
- Container registries - docker.io, registry.k8s.io are US-hosted
- Let's Encrypt - US-based CA (ISRG)
For strict sovereignty requirements:
| Gap | Alternative | Notes |
|---|---|---|
| GitHub | GitLab (gitlab.com) | Netherlands-based, or self-host |
| GitHub | Gitea | Self-host on Scaleway |
| GitHub Actions | GitLab CI/CD | Included with GitLab |
| Container images | Scaleway Container Registry | rg.fr-par.scw.cloud/project/ |
| Let's Encrypt | Buypass (Norway) | EU-based ACME CA |
Mirror images to Scaleway:
# Pull, tag, push to Scaleway Container Registry
docker pull quay.io/jetstack/cert-manager-controller:v1.17.2
docker tag quay.io/jetstack/cert-manager-controller:v1.17.2 rg.fr-par.scw.cloud/__PROJECT_NAME__/cert-manager-controller:v1.17.2
docker push rg.fr-par.scw.cloud/__PROJECT_NAME__/cert-manager-controller:v1.17.2- Sovereign Cloud Stack - German government-backed sovereign cloud standards
- Gaia-X - European data infrastructure initiative
- EUCLIDIA - European Cloud Industrial Alliance