14 min read

Learning Objectives

By the end of this module, you will be able to:

  • Provision a Managed Kubernetes cluster and node pools with Terraform and retrieve the kubeconfig directly from state
  • Deploy Deployments, Services, ConfigMaps, and Secrets to MKS, pulling images from Private Container Registry with `imagePullSecrets`
  • Expose application traffic correctly given that the `LoadBalancer` Service type on MKS is not a true external load balancer, and front the cluster with a separately provisioned Application Load Balancer
  • Manage node pools programmatically: scale node counts, run version upgrades, and isolate workloads across multiple pools
  • Debug running workloads with `kubectl logs`, `exec`, and events, knowing which signals the IONOS platform does and does not surface

Unit 3.2: Kubernetes Deployment and Operations

Introduction

You containerized TaskBoard's API, frontend, and worker in Unit 3.1 and pushed them to Private Container Registry with git-SHA tags. Now you need somewhere to run them. In this unit you provision a Managed Kubernetes (MKS) cluster as code, wire the registry credentials into the cluster so it can pull your images, and deploy all three services as standard Kubernetes resources.

The MKS control plane is managed for you, but two IONOS-specific realities shape every deployment you write. First, a Service of type LoadBalancer on MKS does not provision a real external load balancer, so production ingress is fronted by a separately provisioned Application Load Balancer (ALB). Second, the control-plane events you might expect in a centralized log stream are not there, which changes how you debug. You will write code for both realities, not work around them after they bite you in production.

1. Provisioning the Cluster with Terraform

A Managed Kubernetes cluster on IONOS is two distinct resources: the cluster (the managed control plane) and one or more node pools (the worker compute you pay for). The control plane itself is free of charge; you pay only for the underlying node-pool compute and the Block Storage volumes your pods provision. Create the cluster first, then attach node pools that reference it.

1.1 Cluster and Node Pool Resources

The ionoscloud_k8s_cluster resource defines the control plane and its Kubernetes version. The ionoscloud_k8s_node_pool resource defines worker nodes inside a specific data center.

resource "ionoscloud_k8s_cluster" "taskboard" {
  name        = "taskboard-prod"
  k8s_version = "1.34"

  maintenance_window {
    day_of_the_week = "Sunday"
    time            = "03:00:00Z"
  }
}

resource "ionoscloud_k8s_node_pool" "app" {
  name              = "taskboard-app-pool"
  k8s_cluster_id    = ionoscloud_k8s_cluster.taskboard.id
  datacenter_id     = ionoscloud_datacenter.taskboard.id
  k8s_version       = ionoscloud_k8s_cluster.taskboard.k8s_version
  cpu_family        = "INTEL_SKYLAKE"
  server_type       = "DedicatedCore"
  node_count        = 3
  cores_count       = 4
  ram_size          = 8192
  availability_zone = "AUTO"
  storage_type      = "SSD"
  storage_size      = 100
}

Supported Kubernetes versions are 1.34, 1.33, 1.32, and 1.31. Pin an explicit k8s_version rather than tracking latest, because every node pool upgrade is executed during the maintenance window and can cause disconnections. Node pools accept both DedicatedCore and vCPU server types, so size TaskBoard's app pool on Dedicated Core for predictable performance.

1.2 Retrieving the kubeconfig from State

You do not download the kubeconfig from a UI. The cluster resource exposes the config directly, so Terraform can write it to disk for kubectl and CI to consume.

output "kubeconfig" {
  value     = ionoscloud_k8s_cluster.taskboard.kube_config
  sensitive = true
}

resource "local_file" "kubeconfig" {
  content         = ionoscloud_k8s_cluster.taskboard.kube_config
  filename        = "${path.module}/kubeconfig.yaml"
  file_permission = "0600"
}
export KUBECONFIG=$(pwd)/kubeconfig.yaml
kubectl get nodes

The kubeconfig is also retrievable over the API at GET /k8s/{k8sClusterId}/kubeconfig and via ionosctl k8s kubeconfig get --cluster-id <id>. Treat it as a secret: it grants full cluster access. Mark the Terraform output sensitive and never commit the generated file.

2. Deploying Workloads to MKS

Once kubectl reaches the cluster, MKS behaves as standard upstream Kubernetes. There is no IONOS-specific manifest dialect. You apply Deployments, Services, ConfigMaps, and Secrets exactly as you would on any conformant cluster. The CNI is Calico and is fixed, so network policy authoring follows Calico semantics with no option to swap the plugin.

2.1 Deployment, ConfigMap, and Secret

TaskBoard's API needs non-secret configuration and secret credentials. Separate them: a ConfigMap for the database host and feature flags, a Secret for the connection password.

apiVersion: v1
kind: ConfigMap
metadata:
  name: taskboard-config
  namespace: taskboard
data:
  DB_HOST: "pg-cluster.taskboard.internal"
  CACHE_TTL: "300"
---
apiVersion: v1
kind: Secret
metadata:
  name: taskboard-db
  namespace: taskboard
type: Opaque
stringData:
  DB_PASSWORD: "REPLACED_FROM_TERRAFORM_OUTPUT"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: taskboard-api
  namespace: taskboard
spec:
  replicas: 3
  selector:
    matchLabels:
      app: taskboard-api
  template:
    metadata:
      labels:
        app: taskboard-api
    spec:
      imagePullSecrets:
        - name: registry-cred
      containers:
        - name: api
          image: <registry-name>.cr.de-fra.ionos.com/taskboard-api:<git-sha>
          ports:
            - containerPort: 8080
          envFrom:
            - configMapRef:
                name: taskboard-config
          env:
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: taskboard-db
                  key: DB_PASSWORD
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 15

Secret data is encrypted at rest in MKS, but Kubernetes Secrets are only base64-encoded in the manifest, so still keep the source values out of Git and inject them from Terraform output at deploy time. The readiness probe matters because rolling updates wait on it before shifting traffic.

2.2 Pulling Images with imagePullSecrets

Private Container Registry requires authentication for every pull, and it is token-based docker login only with no RBAC and no per-team repositories. Kubernetes needs a dockerconfigjson Secret built from a registry token, referenced as imagePullSecrets in the pod spec above.

kubectl create namespace taskboard

kubectl create secret docker-registry registry-cred \
  --namespace taskboard \
  --docker-server=<registry-name>.cr.de-fra.ionos.com \
  --docker-username=<token-name> \
  --docker-password=<registry-token>

If you omit the imagePullSecrets reference, or scope the Secret to the wrong namespace, pods stall in ImagePullBackOff. The Secret is namespaced, so create it in every namespace that runs registry images. In CI you generate the registry token once and store it as a pipeline secret, then create the Kubernetes Secret as a deploy step.

3. Exposing Traffic: the LoadBalancer Reality

This is the single most important IONOS-specific fact in the unit. A Service of type LoadBalancer on MKS does not provision a true external load balancer. IONOS Cloud reserves a static public IP and assigns it as a secondary IP to one worker node, which becomes the ingress node, and kube-proxy then NATs traffic to the target pod.

3.1 What This Means for Your Manifests

Two consequences follow directly. The client source IP is lost unless you set externalTrafficPolicy: Local, and throughput is capped at that single ingress node's 2 Gbit/s public ceiling because all traffic funnels through one node. There is no automatic HA across nodes for that IP.

apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx
  namespace: ingress
spec:
  type: LoadBalancer
  externalTrafficPolicy: Local
  selector:
    app: ingress-nginx
  ports:
    - port: 443
      targetPort: 8443

To scale traffic beyond a single node, you reserve multiple LB IP addresses and distribute them across multiple ingress nodes using DNS load balancing. Because only public node pools support the LoadBalancer Service type at all (private node pools do not), keep your internet-facing ingress controller on a public pool. Expose only the ingress controller as LoadBalancer, never one Service per app.

3.2 Fronting the Cluster with a Separately Provisioned ALB

For production TaskBoard, provision an Application Load Balancer separately via Terraform. The ALB is not auto-created from any Kubernetes manifest, so there is no ingress controller annotation that conjures one. You provision it as infrastructure and point its forwarding rules at the node IPs serving the ingress controller.

resource "ionoscloud_application_loadbalancer" "taskboard" {
  name          = "taskboard-alb"
  datacenter_id = ionoscloud_datacenter.taskboard.id
  listener_lan  = ionoscloud_lan.public.id
  ips           = [ionoscloud_ipblock.alb.ips[0]]
  target_lan    = ionoscloud_lan.app.id
}

resource "ionoscloud_application_loadbalancer_forwardingrule" "https" {
  datacenter_id               = ionoscloud_datacenter.taskboard.id
  application_loadbalancer_id = ionoscloud_application_loadbalancer.taskboard.id
  name                        = "https-rule"
  protocol                    = "HTTP"
  listener_ip                 = ionoscloud_ipblock.alb.ips[0]
  listener_port               = 443
}

Note that NSG rules do not apply to the ALB. Network Security Groups bind at the server-NIC level, not to the Managed ALB or to MKS, so you control ingress traffic through ALB forwarding rules and the security rules on the worker node NICs, not by attaching an NSG to the load balancer.

4. Node Pool Operations

Node pools are the operational surface you manage over the cluster's lifetime: scaling for load, upgrading Kubernetes versions, and isolating workloads. Nodes themselves are immutable, so configuration changes that touch a node replace it rather than mutating it in place.

4.1 Scaling and Workload Isolation

Scaling a pool is a node_count change applied through Terraform or the API. A node pool holds up to 100 nodes (20 recommended), a cluster holds up to 500 node pools (50 recommended) and up to 5000 nodes total, and each node runs up to 110 pods with up to 20 attached volumes. Use separate pools to isolate workloads, for example a Dedicated Core pool for the latency-sensitive API and a vCPU pool for the background worker.

resource "ionoscloud_k8s_node_pool" "worker" {
  name           = "taskboard-worker-pool"
  k8s_cluster_id = ionoscloud_k8s_cluster.taskboard.id
  datacenter_id  = ionoscloud_datacenter.taskboard.id
  k8s_version    = ionoscloud_k8s_cluster.taskboard.k8s_version
  server_type    = "vCPU"
  node_count     = 2
  cores_count    = 2
  ram_size       = 4096
  storage_type   = "SSD"
  storage_size   = 50
}

Schedule the worker Deployment onto this pool with a nodeSelector matching the pool, keeping it off the API nodes.

4.2 Version Upgrades

Bump k8s_version on the node pool to upgrade. The operation aligns resources in the target data center and the pool returns to Active when complete, but it runs during maintenance and can cause disconnections, so do it deliberately. Keep the control-plane minor version and node-pool versions within the supported skew, and upgrade the control plane before the node pools.

ionosctl k8s nodepool update \
  --cluster-id <cluster-id> \
  --nodepool-id <nodepool-id> \
  --k8s-version 1.34

Persistent volumes are provisioned by the IONOS CSI driver (cloud.ionos.com provisioner) backed by Block Storage, so pods with PVCs reschedule onto replacement nodes during an upgrade without losing data.

5. Debugging Workloads on MKS

When a deployment misbehaves, work from the pod outward. The standard kubectl triage chain applies, but one platform gap changes your strategy: Kubernetes control-plane events do not flow through the IONOS Logging Service, so do not expect to find scheduler or API-server logs there.

5.1 The Triage Chain

# pod status and restart counts
kubectl get pods -n taskboard -o wide

# why a pod is stuck (events at the bottom)
kubectl describe pod taskboard-api-7d9f -n taskboard

# application stdout/stderr, including the previous crashed container
kubectl logs taskboard-api-7d9f -n taskboard --previous

# cluster-scoped events, newest last
kubectl get events -n taskboard --sort-by=.lastTimestamp

# shell into a running container to test connectivity
kubectl exec -it taskboard-api-7d9f -n taskboard -- sh

kubectl describe is where ImagePullBackOff, failed scheduling, and probe failures surface, so read it before reaching for logs. For application-level observability, forward your own container logs to the Logging Service explicitly, because the platform will not capture control-plane signals for you.

5.2 Knowing the Platform Boundary

If you assume control-plane events are centrally logged, you will waste hours searching a stream that never contained them. Configure cluster-level log forwarding (for example a Fluent Bit DaemonSet shipping pod logs to the Logging Service) for the application logs you do control, and rely on kubectl get events for control-plane visibility. Pair this with the readiness and liveness probes from Section 2 so the cluster restarts and reschedules unhealthy pods automatically while you investigate.

API Reference Quick Card

Key API endpoints for Managed Kubernetes:

Method Endpoint Description
GET /k8s List all Kubernetes clusters
POST /k8s Create a new cluster
GET /k8s/{k8sClusterId}/kubeconfig Retrieve the cluster kubeconfig
POST /k8s/{k8sClusterId}/nodepools Create a node pool
PUT /k8s/{k8sClusterId}/nodepools/{nodepoolId} Scale or upgrade a node pool

Base URL: https://api.ionos.com/cloudapi/v6 Authentication: Authorization: Bearer <token>

Code Lab

Objective: Provision an MKS cluster with Terraform, deploy TaskBoard's API from Private Container Registry, and reach it through an ingress controller.

Prerequisites:

  • IONOS Cloud account with API token (IONOS_TOKEN exported)
  • Terraform and the ionos-cloud/ionoscloud provider
  • kubectl and ionosctl installed
  • A Private Container Registry with the taskboard-api image pushed (Unit 3.1)

Step 1: Provision the cluster and node pool

terraform init
terraform apply -auto-approve

Expected output:

ionoscloud_k8s_cluster.taskboard: Creation complete
ionoscloud_k8s_node_pool.app: Creation complete
Apply complete! Resources: 2 added.

Step 2: Write the kubeconfig and connect

terraform output -raw kubeconfig > kubeconfig.yaml
chmod 600 kubeconfig.yaml
export KUBECONFIG=$(pwd)/kubeconfig.yaml
kubectl get nodes

Expected output:

NAME                  STATUS   ROLES    AGE   VERSION
taskboard-app-pool-1  Ready    <none>   2m    v1.34.x
taskboard-app-pool-2  Ready    <none>   2m    v1.34.x
taskboard-app-pool-3  Ready    <none>   2m    v1.34.x

Step 3: Create namespace and registry pull secret

kubectl create namespace taskboard
kubectl create secret docker-registry registry-cred \
  --namespace taskboard \
  --docker-server=<registry-name>.cr.de-fra.ionos.com \
  --docker-username=<token-name> \
  --docker-password=<registry-token>

Expected output:

namespace/taskboard created
secret/registry-cred created

Step 4: Deploy the API, ConfigMap, and Secret

kubectl apply -f taskboard-api.yaml
kubectl rollout status deployment/taskboard-api -n taskboard

Expected output:

deployment "taskboard-api" successfully rolled out

Step 5: Verify pods are pulling and running

kubectl get pods -n taskboard

Expected output:

NAME                            READY   STATUS    RESTARTS   AGE
taskboard-api-7d9f8c-abcde      1/1     Running   0          40s
taskboard-api-7d9f8c-fghij      1/1     Running   0          40s
taskboard-api-7d9f8c-klmno      1/1     Running   0          40s

Step 6: Expose the ingress controller and read its IP

kubectl apply -f ingress.yaml
kubectl get svc ingress-nginx -n ingress

Expected output:

NAME            TYPE           EXTERNAL-IP      PORT(S)
ingress-nginx   LoadBalancer   <reserved-ip>    443:3xxxx/TCP

Step 7: Confirm the endpoint responds

curl -sk https://<reserved-ip>/healthz

Expected output:

{"status":"ok"}

Validation Checklist:

  • [ ] Cluster and node pool reach Active and nodes are Ready
  • [ ] API pods are Running, not ImagePullBackOff
  • [ ] The ingress IP returns a healthy response from the API

Cleanup:

kubectl delete namespace taskboard
terraform destroy -auto-approve

Common Pitfalls

Developer mistakes to avoid when deploying to MKS:

  1. Expecting type: LoadBalancer to give you a real external load balancer

    • Problem: You expose every Service as LoadBalancer, expect HA across nodes, and source IPs all show up as a single node address.
    • Why it happens: On MKS a LoadBalancer Service reserves a static IP and pins it to one worker node as a secondary IP; kube-proxy NATs to the pod and the source IP is lost.
    • Fix: Expose only the ingress controller as LoadBalancer, set externalTrafficPolicy: Local to preserve the source IP, and front production traffic with a separately provisioned ALB:
    spec:
      type: LoadBalancer
      externalTrafficPolicy: Local
    
  2. ImagePullBackOff from a missing or misscoped pull secret

    • Problem: Pods never start; kubectl describe pod shows Failed to pull image ... no basic auth credentials.
    • Why it happens: Private Container Registry requires authentication on every pull, and the dockerconfigjson Secret is namespaced. A Secret in default does nothing for pods in taskboard.
    • Fix: Create the registry Secret in the workload's namespace and reference it under imagePullSecrets:
    kubectl create secret docker-registry registry-cred -n taskboard \
      --docker-server=<registry-name>.cr.de-fra.ionos.com \
      --docker-username=<token-name> --docker-password=<registry-token>
    
  3. Searching the Logging Service for control-plane events

    • Problem: A pod will not schedule and you spend an hour searching centralized logs for scheduler errors that are not there.
    • Why it happens: Kubernetes control-plane events do not flow through the IONOS Logging Service.
    • Fix: Read control-plane signals with kubectl, and forward only the application logs you control:
    kubectl get events -n taskboard --sort-by=.lastTimestamp
    kubectl describe pod <pod> -n taskboard
    

Summary

You can now provision a Managed Kubernetes cluster and node pools entirely as code, retrieve the kubeconfig from Terraform state, and deploy containerized services that pull from Private Container Registry. You also know the two IONOS realities that separate a working MKS deployment from a broken one: the LoadBalancer Service type is not a true external load balancer, so production traffic is fronted by a separately provisioned ALB, and control-plane events are not centrally logged, so debugging runs through kubectl plus your own log forwarding.

With TaskBoard's API, frontend, and worker running on MKS behind an ALB, you have a deployable target. The next unit automates the path from a Git commit to this running cluster.

Key Points:

  • The MKS control plane is free of charge; you pay only for node-pool compute and Block Storage volumes
  • Retrieve the kubeconfig from the ionoscloud_k8s_cluster resource's kube_config attribute, marked sensitive
  • A type: LoadBalancer Service pins a static IP to one worker node and is not a true external LB; front production with a separately provisioned ALB
  • Pull private images with a namespaced docker-registry Secret referenced as imagePullSecrets
  • Kubernetes control-plane events do not reach the IONOS Logging Service; debug with kubectl and forward application logs yourself

Important Terminology:

  • Node pool: A group of worker nodes of one server type in one data center, scaled and upgraded as a unit (up to 100 nodes, 20 recommended)
  • kubeconfig: The credential and connection file for kubectl, exposed by the cluster resource and treated as a secret
  • imagePullSecrets: A pod-spec reference to a dockerconfigjson Secret that authenticates pulls from Private Container Registry
  • externalTrafficPolicy: Local: A Service setting that preserves the client source IP by keeping traffic on the receiving node instead of re-routing it
  • CSI provisioner (cloud.ionos.com): The IONOS storage driver that backs PersistentVolumeClaims with Block Storage so data survives node replacement

Next Steps

Continue Learning: Unit 3.3: CI/CD Pipelines for IONOS

Related Topics: