Your First Migration: From Zero to Production in Weeks
Step-by-step: choose pioneer workload, set up OpenTofu, use Flux, encrypt secrets with SOPS, deploy to Kubernetes. Real pattern from our experience.
By Jurg van Vliet
Published Nov 28, 2025
Week-by-Week Timeline
Moving your first workload to European infrastructure in four weeks. This is realistic for a stateless application with an experienced team. Adjust the timeline based on complexity and organisational constraints.
Week 1: Infrastructure Setup
Day 1-2: Provider selection and access
Choose your provider based on requirements (see our evaluation criteria article). Create account, set up billing, configure initial access.
For this guide, we'll use Scaleway, but the patterns apply to OVHcloud, Hetzner, or IONOS.
# Install tools
brew install opentofu kubectl flux age sops
# Configure Scaleway CLI
scw init
Day 3-5: Infrastructure as Code
Create OpenTofu configuration for your cluster:
# main.tf
terraform {
required_providers {
scaleway = {
source = "scaleway/scaleway"
version = "~> 2.0"
}
}
}
variable "cluster_name" {
default = "pilot-cluster"
}
resource "scaleway_k8s_cluster" "main" {
name = var.cluster_name
version = "1.28.5"
cni = "cilium"
region = "fr-par"
autoscaler_config {
disable_scale_down = false
scale_down_delay_after_add = "5m"
}
}
resource "scaleway_k8s_pool" "main" {
cluster_id = scaleway_k8s_cluster.main.id
name = "main-pool"
node_type = "DEV1-M"
size = 3
autoscaling = true
min_size = 3
max_size = 6
}
output "kubeconfig" {
value = scaleway_k8s_cluster.main.kubeconfig[0].config_file
sensitive = true
}
Apply the configuration:
tofu init
tofu plan
tofu apply
# Get kubeconfig
tofu output -raw kubeconfig > kubeconfig.yaml
export KUBECONFIG=$(pwd)/kubeconfig.yaml
# Verify cluster access
kubectl get nodes
Week 2: Application Deployment
Day 1-2: Prepare application manifests
If your application isn't yet in Kubernetes format, containerise and create manifests:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: default
spec:
replicas: 2
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: app
image: your-registry.com/myapp:v1.0.0
ports:
- containerPort: 8080
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-url
---
apiVersion: v1
kind: Service
metadata:
name: myapp
spec:
selector:
app: myapp
ports:
- port: 80
targetPort: 8080
Day 3-4: Test deployment manually
Deploy and verify functionality:
# Create namespace
kubectl create namespace myapp
# Deploy application
kubectl apply -f deployment.yaml -n myapp
# Check status
kubectl get pods -n myapp
kubectl logs -f deployment/myapp -n myapp
# Test locally
kubectl port-forward svc/myapp 8080:80 -n myapp
curl http://localhost:8080
Day 5: Handle secrets
Generate age key for encryption:
# Generate age keypair
age-keygen -o age.key
# Public key for .sops.yaml: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# Private key stored in: age.key
Create and encrypt secrets:
# Create plaintext secret
cat > secrets.yaml <<EOF
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
namespace: myapp
stringData:
database-url: "postgresql://user:password@host/db"
api-key: "secret-api-key-here"
EOF
# Configure SOPS
cat > .sops.yaml <<EOF
creation_rules:
- path_regex: .*secrets.enc.yaml$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
EOF
# Encrypt
sops -e secrets.yaml > secrets.enc.yaml
# Commit encrypted version only
git add secrets.enc.yaml .sops.yaml
git commit -m "Add encrypted application secrets"
# Delete plaintext (important!)
rm secrets.yaml
Week 3: GitOps Setup
Day 1-2: Bootstrap Flux
# Install Flux controllers
flux bootstrap github \
--owner=your-org \
--repository=your-repo \
--path=gitops/clusters/pilot \
--personal=false
# Create SOPS decryption secret
kubectl create secret generic sops-age \
--namespace=flux-system \
--from-file=age.agekey=age.key
Day 3-4: Migrate manifests to GitOps
Create repository structure:
gitops/
├── clusters/
│ └── pilot/
│ └── flux-system/
├── infrastructure/
│ └── pilot/
│ └── cert-manager/
└── apps/
└── pilot/
├── myapp/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── secrets.enc.yaml
└── kustomization.yaml
Create Flux Kustomization:
# gitops/clusters/pilot/apps.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps
namespace: flux-system
spec:
interval: 10m
path: ./gitops/apps/pilot
prune: true
sourceRef:
kind: GitRepository
name: flux-system
decryption:
provider: sops
secretRef:
name: sops-age
Commit and push:
git add gitops/
git commit -m "Add GitOps structure and application manifests"
git push
# Watch Flux reconcile
flux get kustomizations --watch
Day 5: Verify GitOps workflow
Test the full cycle:
# Make a change in git
# Edit gitops/apps/pilot/myapp/deployment.yaml
# Change replica count from 2 to 3
git commit -am "Scale myapp to 3 replicas"
git push
# Watch Flux apply the change
flux reconcile kustomization apps --with-source
kubectl get pods -n myapp -w
Week 4: Testing and Cutover
Day 1-2: Comprehensive testing
Run your full test suite against the new deployment:
# Port forward for testing
kubectl port-forward svc/myapp 8080:80 -n myapp
# Run integration tests
API_BASE_URL=http://localhost:8080 npm test
# Load testing (if applicable)
# Performance comparison with old environment
Day 3: Traffic split preparation
Set up Gateway API for gradual traffic shifting (if moving from existing deployment):
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: myapp-route
spec:
parentRefs:
- name: main-gateway
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: myapp-old # Existing deployment
port: 80
weight: 90
- name: myapp # New deployment
port: 80
weight: 10 # Start with 10% traffic
Day 4: Execute cutover
Gradually shift traffic:
# Hour 0: 10% new, 90% old (already set)
# Monitor error rates, latency, logs
# Hour 2: 50% new, 50% old (if no issues)
# Update HTTPRoute weights
# Hour 4: 100% new, 0% old (if still no issues)
# Update HTTPRoute to remove old backend
# Monitor for 24 hours before removing old infrastructure
Day 5: Documentation and cleanup
Document what you learned:
- What worked well
- What was harder than expected
- What you'd do differently
- Actual vs estimated timeline
Clean up old infrastructure once stable.
Common Pitfalls
Underestimating DNS propagation: Allow time for DNS changes to propagate (up to 48 hours for some zones, though usually much faster).
Missing monitoring: Set up monitoring before cutover, not after. You need visibility into the new environment.
Insufficient testing: Test failure scenarios, not just happy paths. What happens when the database is unreachable?
Secrets in logs: Review logs to ensure secrets aren't being logged. This is an easy mistake to make.
No rollback plan: Know exactly how to switch back to the old environment if needed.
Success Criteria
You've succeeded when:
- Application runs stable on new infrastructure
- All tests pass
- Traffic is served with acceptable latency
- Monitoring shows no elevated error rates
- GitOps workflow is working (changes via git)
- Team knows how to debug and make changes
- Documentation is complete
Then celebrate, review lessons learned, and plan the next migration.
#migration #kubernetes #gitops #stepbystep #infrastructure