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_TOKENexported) - Terraform and the
ionos-cloud/ionoscloudprovider kubectlandionosctlinstalled- A Private Container Registry with the
taskboard-apiimage 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
Activeand nodes areReady - [ ] API pods are
Running, notImagePullBackOff - [ ] 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:
-
Expecting
type: LoadBalancerto 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
LoadBalancerService 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, setexternalTrafficPolicy: Localto preserve the source IP, and front production traffic with a separately provisioned ALB:
spec: type: LoadBalancer externalTrafficPolicy: Local - Problem: You expose every Service as
-
ImagePullBackOfffrom a missing or misscoped pull secret- Problem: Pods never start;
kubectl describe podshowsFailed to pull image ... no basic auth credentials. - Why it happens: Private Container Registry requires authentication on every pull, and the
dockerconfigjsonSecret is namespaced. A Secret indefaultdoes nothing for pods intaskboard. - 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> - Problem: Pods never start;
-
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_clusterresource'skube_configattribute, marked sensitive - A
type: LoadBalancerService 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-registrySecret referenced asimagePullSecrets - Kubernetes control-plane events do not reach the IONOS Logging Service; debug with
kubectland 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
dockerconfigjsonSecret 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: