Featured

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