16 min read

Learning Objectives

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

  • Build a GitHub Actions workflow that builds a container, pushes it to IONOS Container Registry, and deploys it to Managed Kubernetes on every push to `main`
  • Configure an equivalent GitLab CI pipeline with IONOS-specific build, push, and deploy stages
  • Run Terraform in CI/CD with `plan` on pull requests and `apply` on merge, managing `IONOS_TOKEN` and other secrets safely
  • Implement deployment strategies and rollback using `kubectl rollout` and Terraform state recovery
  • Extract reusable Terraform modules from the TaskBoard infrastructure to build a team module library

Unit 3.3: CI/CD Pipelines for IONOS

Introduction

You have containerized TaskBoard, pushed its images to IONOS Container Registry, and deployed it to Managed Kubernetes by hand. Running docker push and kubectl apply from your laptop does not scale to a team, and it does not survive the moment you forget which image tag is in production. In this unit you wire the whole loop together so that a git push is the only manual step. A commit triggers a build, the image lands in Container Registry tagged with the git SHA, and the new tag rolls out to the cluster.

You will also bring infrastructure under the same discipline. Terraform plan runs on every pull request so reviewers see the diff before anything changes, and apply runs only after merge. Two IONOS constraints shape every pipeline you write here: the API is asynchronous, so deploys must wait for readiness rather than assume it, and Container Registry authentication is token-based docker login only, with no RBAC and no per-team repositories, so credentials live in CI secrets.

1. Pipeline Design for IONOS

A CI/CD pipeline on IONOS splits into two distinct flows that should not share a job. Application changes follow build -> test -> push image -> deploy to Kubernetes. Infrastructure changes follow plan -> apply. Mixing them means a typo in a Kubernetes manifest can block an urgent infrastructure fix, and a slow terraform apply delays every application deploy.

The application flow ends with kubectl apply or, more precisely, a controlled image-tag update against an existing Deployment. The infrastructure flow ends with terraform apply against your IONOS resources. Both flows authenticate to IONOS, but they use different credentials: the application flow needs a Container Registry token and a kubeconfig, while the infrastructure flow needs an IONOS_TOKEN bearer token for the Cloud API.

1.1 Tagging Images with the Git SHA

Never deploy :latest. A mutable tag makes rollbacks ambiguous and breaks the link between a running pod and the commit that produced it. Tag every image with the immutable git SHA so the running revision is always traceable.

# Derive an immutable tag from the commit
REGISTRY="taskboard.cr.de-fra.ionos.com"
SHA=$(git rev-parse --short HEAD)
IMAGE="${REGISTRY}/taskboard-api:${SHA}"

docker build -t "${IMAGE}" ./api
docker push "${IMAGE}"

The registry hostname follows the pattern {registry-name}.cr.{location-with-dash}.ionos.com, for example tue1608es.cr.es-vit.ionos.com. The hostname is allocated only after the registry reaches the Running state and is empty until then, so a pipeline that provisions a fresh registry must read the hostname from Terraform output rather than hardcode it.

1.2 Separating the Application and Infrastructure Repositories

Keep Terraform in an infrastructure repository and Kubernetes manifests plus application code in an application repository. The infrastructure repo applies on merge; the application repo builds and deploys on push. This separation keeps the blast radius of a bad change small and lets you grant different teams different access.

taskboard-app/          # build, push, kubectl deploy
  api/Dockerfile
  k8s/deployment.yaml
  .github/workflows/deploy.yml
taskboard-infra/        # terraform plan/apply
  main.tf
  modules/
  .github/workflows/terraform.yml

2. GitHub Actions for IONOS

A GitHub Actions workflow for the application flow has three logical stages in a single job: build and test, docker login and push, then deploy to Managed Kubernetes. The IONOS-specific parts are the registry login (token-based) and the kubeconfig retrieval.

2.1 Storing IONOS Secrets

Tokens never belong in the repository. Store the Container Registry token and the kubeconfig as repository or environment secrets. The Container Registry token password is shown only once at creation time, so capture it immediately and store it in CI. A registry token can be permanent or temporary with an expiryDate, and on expiry the token is deleted rather than disabled, which means an expired token in CI fails docker login outright rather than silently degrading.

Secret Name Contents Used By
CR_USERNAME Container Registry token name docker login
CR_PASSWORD Container Registry token password (shown once) docker login
KUBECONFIG base64-encoded kubeconfig from Terraform output kubectl
IONOS_TOKEN Cloud API bearer token Terraform / ionosctl

2.2 The Deploy Workflow

# .github/workflows/deploy.yml
name: deploy-taskboard
on:
  push:
    branches: [main]

env:
  REGISTRY: taskboard.cr.de-fra.ionos.com
  IMAGE_NAME: taskboard-api

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set image tag
        run: echo "TAG=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"

      - name: Run tests
        run: |
          cd api
          pip install -r requirements.txt
          pytest

      - name: Log in to IONOS Container Registry
        run: echo "${{ secrets.CR_PASSWORD }}" | docker login "$REGISTRY" \
             -u "${{ secrets.CR_USERNAME }}" --password-stdin

      - name: Build and push image
        run: |
          docker build -t "$REGISTRY/$IMAGE_NAME:$TAG" ./api
          docker push "$REGISTRY/$IMAGE_NAME:$TAG"

      - name: Configure kubectl
        run: |
          mkdir -p "$HOME/.kube"
          echo "${{ secrets.KUBECONFIG }}" | base64 -d > "$HOME/.kube/config"

      - name: Deploy to Managed Kubernetes
        run: |
          kubectl set image deployment/taskboard-api \
            api="$REGISTRY/$IMAGE_NAME:$TAG"
          kubectl rollout status deployment/taskboard-api --timeout=180s

kubectl set image updates only the image field on the existing Deployment, which triggers a rolling update without re-applying the whole manifest. The kubectl rollout status step blocks until the rollout completes or the timeout fires, which is the correct way to surface a failed deploy as a failed pipeline. The kubeconfig itself comes from the Managed Kubernetes cluster: you download it from the cluster settings or, in an automated pipeline, read it from Terraform output (covered in Unit 3.2) and store the base64-encoded value as the KUBECONFIG secret.

3. GitLab CI for IONOS

GitLab CI expresses the same flow as discrete stages in .gitlab-ci.yml. The IONOS specifics are identical: token-based docker login to the registry and a kubeconfig for the cluster. GitLab provides a Docker-in-Docker service for building images inside the pipeline.

3.1 Pipeline Stages

# .gitlab-ci.yml
stages: [test, build, deploy]

variables:
  REGISTRY: taskboard.cr.de-fra.ionos.com
  IMAGE_NAME: taskboard-api

test:
  stage: test
  image: python:3.12
  script:
    - cd api && pip install -r requirements.txt && pytest

build:
  stage: build
  image: docker:24
  services: [docker:24-dind]
  script:
    - export TAG=$CI_COMMIT_SHORT_SHA
    - echo "$CR_PASSWORD" | docker login "$REGISTRY" -u "$CR_USERNAME" --password-stdin
    - docker build -t "$REGISTRY/$IMAGE_NAME:$TAG" ./api
    - docker push "$REGISTRY/$IMAGE_NAME:$TAG"

deploy:
  stage: deploy
  image: bitnami/kubectl:latest
  script:
    - echo "$KUBECONFIG_B64" | base64 -d > /tmp/kubeconfig
    - export KUBECONFIG=/tmp/kubeconfig
    - kubectl set image deployment/taskboard-api api="$REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA"
    - kubectl rollout status deployment/taskboard-api --timeout=180s
  only: [main]

Store CR_USERNAME, CR_PASSWORD, and KUBECONFIG_B64 as masked, protected CI/CD variables in the GitLab project settings. Protected variables are exposed only to pipelines running on protected branches, which keeps production credentials out of feature-branch pipelines. CI_COMMIT_SHORT_SHA is the GitLab equivalent of the git SHA tag, giving the same immutable-tag guarantee as the GitHub Actions example.

4. Terraform in CI/CD

Infrastructure changes deserve review before they hit IONOS resources, because terraform apply creates billable resources and can destroy them. The standard pattern runs terraform plan on every pull request and posts the plan as a PR comment, then runs terraform apply only after merge to main. This gives reviewers the exact diff and prevents anyone applying unreviewed changes.

4.1 Plan on PR, Apply on Merge

# .github/workflows/terraform.yml
name: terraform
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

env:
  IONOS_TOKEN: ${{ secrets.IONOS_TOKEN }}

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform plan -no-color -out=tfplan
      - run: terraform show -no-color tfplan > plan.txt
      - uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('plan.txt', 'utf8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '```\n' + plan.slice(0, 60000) + '\n```'
            });

  apply:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform apply -auto-approve

The IONOS Terraform provider authenticates from the IONOS_TOKEN environment variable, so passing the bearer token as an env var is all the provider block needs. The provider polls IONOS asynchronous operations internally, so terraform apply blocks until each resource reaches its ready state before moving on. This means the pipeline does not need its own readiness loops for Terraform-managed resources.

4.2 Remote State for CI/CD

Local state does not work in CI/CD because each runner starts with a clean checkout. Use the S3-compatible IONOS Object Storage backend so every pipeline run reads and writes the same state, and so state locking prevents two concurrent applies from corrupting it.

terraform {
  backend "s3" {
    bucket                      = "taskboard-tfstate"
    key                         = "infra/terraform.tfstate"
    region                      = "de"
    endpoints = { s3 = "https://s3-eu-central-1.ionoscloud.com" }
    skip_credentials_validation = true
    skip_region_validation      = true
    skip_requesting_account_id  = true
  }
}

Object Storage uses Access Key plus Secret Key authentication, not bearer tokens, so the backend credentials are separate from IONOS_TOKEN. Supply them to the pipeline as AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets, which the S3 backend reads automatically.

5. Deployment Strategies and Rollback

The default Kubernetes Deployment strategy is RollingUpdate, which replaces pods incrementally so the service stays available during a deploy. For workloads that cannot run two versions at once, use Recreate, which terminates all old pods before starting new ones at the cost of brief downtime.

5.1 Configuring the Strategy

apiVersion: apps/v1
kind: Deployment
metadata:
  name: taskboard-api
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels: { app: taskboard-api }
  template:
    metadata:
      labels: { app: taskboard-api }
    spec:
      imagePullSecrets:
        - name: cr-pull-secret
      containers:
        - name: api
          image: taskboard.cr.de-fra.ionos.com/taskboard-api:abc1234
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
            initialDelaySeconds: 5

maxUnavailable: 0 guarantees no capacity is lost during the rollout, and the readinessProbe ensures traffic only reaches pods that report healthy. The imagePullSecrets entry references the Container Registry token, since the cluster pulls images using the same token-based credentials your pipeline used to push them. Canary and blue-green releases are achievable with parallel Deployments and traffic shifting, or with a GitOps tool such as ArgoCD, which is covered as a pattern in Unit 5.3 rather than here.

5.2 Rolling Back

When a deploy goes wrong, roll the application back with kubectl rollout undo, which returns the Deployment to its previous revision without rebuilding anything.

# Inspect revision history
kubectl rollout history deployment/taskboard-api

# Roll back to the immediately previous revision
kubectl rollout undo deployment/taskboard-api

# Roll back to a specific revision
kubectl rollout undo deployment/taskboard-api --to-revision=4

Because every image is tagged with its git SHA, you can also roll back deterministically with kubectl set image pointing at a known-good tag. For infrastructure, rollback means reverting the offending commit and re-running terraform apply, which reconciles the live resources back to the committed state. If state itself is damaged, restore it from a versioned copy in the Object Storage backend.

6. Building a Terraform Module Library

Once TaskBoard's infrastructure works, extract the repeated patterns into modules so the next service does not start from scratch. A module groups related resources behind input variables and exposes outputs, turning a verbose stack into a few lines of caller code.

6.1 Module Structure

# modules/k8s-service/variables.tf
variable "name"        { type = string }
variable "node_count"  { type = number, default = 2 }
variable "k8s_version" { type = string, default = "1.34" }

# modules/k8s-service/main.tf
resource "ionoscloud_k8s_cluster" "this" {
  name        = var.name
  k8s_version = var.k8s_version
}

resource "ionoscloud_k8s_node_pool" "this" {
  name        = "${var.name}-pool"
  k8s_cluster_id = ionoscloud_k8s_cluster.this.id
  node_count  = var.node_count
  # ... cores, ram, availability_zone, etc.
}

# modules/k8s-service/outputs.tf
output "cluster_id" { value = ionoscloud_k8s_cluster.this.id }

The supported Kubernetes versions are 1.34, 1.33, 1.32, and 1.31, so pin a known-good version in the module default and let callers override it. Node pools can use Dedicated Core or vCPU server types, and a node pool has a hard maximum of 100 nodes with a recommended maximum of 20, so validate node_count against those bounds rather than letting a typo provision an oversized pool.

6.2 Consuming the Module

module "taskboard" {
  source     = "./modules/k8s-service"
  name       = "taskboard-prod"
  node_count = 3
}

output "taskboard_cluster" {
  value = module.taskboard.cluster_id
}

Version your module repository with git tags and reference a specific tag in source so a downstream change to the module does not silently alter an existing stack. This is how a team turns one working deployment into a reusable library.

API Reference Quick Card

Key endpoints used by CI/CD pipelines on IONOS:

Method Endpoint Description
POST /containerregistries/registries/{id}/tokens Create a registry token for CI
GET /k8s/{clusterId}/kubeconfig Retrieve kubeconfig for deploy
GET /k8s/{clusterId}/nodepools List node pools for a cluster
GET /requests/{requestId}/status Poll async operation until DONE
DELETE /containerregistries/registries/{id}/repositories/{name} Delete a repository (name URL-encoded)

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

Code Lab

Objective: Build a GitHub Actions pipeline that builds, pushes, and deploys TaskBoard to Managed Kubernetes on every push to main.

Prerequisites:

  • IONOS Cloud account with API token (IONOS_TOKEN)
  • An existing Container Registry and Managed Kubernetes cluster (from Units 3.1 and 3.2)
  • A GitHub repository for the TaskBoard application
  • docker, kubectl, and ionosctl installed locally

Step 1: Create a registry token for CI

ionosctl container-registry token create \
  --registry-id "$REGISTRY_ID" --name ci-deploy

Expected output:

TokenId    Name        Status   ExpiryDate
a1b2c3d4   ci-deploy   enabled  -
Password: <shown once - copy it now>

Step 2: Store secrets in GitHub

gh secret set CR_USERNAME --body "ci-deploy"
gh secret set CR_PASSWORD --body "<password from step 1>"
gh secret set KUBECONFIG  --body "$(ionosctl k8s kubeconfig get \
  --cluster-id "$CLUSTER_ID" | base64 -w0)"

Expected output:

✓ Set secret CR_USERNAME
✓ Set secret CR_PASSWORD
✓ Set secret KUBECONFIG

Step 3: Add the workflow file Commit .github/workflows/deploy.yml from Section 2.2 to the repository. Expected output:

[main 9f3c1ab] add deploy workflow
 1 file changed, 38 insertions(+)

Step 4: Trigger the pipeline

git commit --allow-empty -m "trigger deploy"
git push origin main

Expected output:

To github.com:you/taskboard-app.git
   8d2e1f0..9f3c1ab  main -> main

Step 5: Watch the run

gh run watch

Expected output:

✓ build-and-deploy succeeded
  ✓ Build and push image
  ✓ Deploy to Managed Kubernetes

Step 6: Verify the rollout on the cluster

kubectl get deployment taskboard-api -o wide

Expected output:

NAME            READY   IMAGE
taskboard-api   3/3     taskboard.cr.de-fra.ionos.com/taskboard-api:9f3c1ab

Validation Checklist:

  • [ ] Image with the git-SHA tag exists in Container Registry
  • [ ] Deployment image matches the pushed tag
  • [ ] kubectl rollout status reported success in the pipeline
  • [ ] A second push produces a new SHA tag and a clean rolling update

Cleanup:

gh secret delete CR_USERNAME CR_PASSWORD KUBECONFIG
ionosctl container-registry token delete --registry-id "$REGISTRY_ID" --token-id "$TOKEN_ID"

Common Pitfalls

Developer mistakes to avoid with CI/CD on IONOS:

  1. Hardcoding the registry hostname before the registry is Running

    • Problem: The pipeline fails docker login with a DNS resolution error against an empty hostname.
    • Why it happens: The registry hostname is allocated only after the registry reaches the Running state, so a freshly provisioned registry has no hostname yet.
    • Fix: Read the hostname from Terraform output instead of hardcoding it, and gate the push job on the registry being ready:
    output "registry_hostname" { value = ionoscloud_container_registry.this.hostname }
    
  2. Expecting a Kubernetes LoadBalancer Service to be a true external load balancer

    • Problem: Throughput plateaus and the source IP of every request shows as a cluster-internal address, breaking rate limiting and audit logs.
    • Why it happens: A LoadBalancer Service on Managed Kubernetes does not provision an external LB. IONOS reserves a static public IP and assigns it as a secondary IP to one worker node, and kube-proxy NATs traffic to the pod, capping throughput at that single node's public ceiling and losing the source IP.
    • Fix: Set externalTrafficPolicy: Local to preserve the source IP, and front the service with a separately provisioned Managed ALB or distribute across multiple LB IPs to exceed one node's throughput:
    spec:
      type: LoadBalancer
      externalTrafficPolicy: Local
    
  3. Running Terraform with local state in CI/CD

    • Problem: Every pipeline run starts with empty state, so terraform apply tries to recreate resources that already exist and errors on duplicate names.
    • Why it happens: CI runners check out a clean workspace with no state file, and there is no shared backend.
    • Fix: Configure the S3-compatible Object Storage backend so all runs share state, and supply Access Key plus Secret Key credentials as pipeline secrets:
    export AWS_ACCESS_KEY_ID="$IONOS_S3_KEY"
    export AWS_SECRET_ACCESS_KEY="$IONOS_S3_SECRET"
    terraform init
    

Summary

You can now drive the full build-test-deploy loop from a single git push. The application pipeline builds a container, tags it with the git SHA, logs in to IONOS Container Registry with a token-based docker login, pushes the image, and rolls it out to Managed Kubernetes with a readiness-gated kubectl set image and kubectl rollout status. The infrastructure pipeline keeps Terraform honest by planning on pull requests and applying only on merge, backed by shared state in Object Storage. When something breaks, kubectl rollout undo and a reverted Terraform commit return you to a known-good state, and your extracted module library means the next service reuses these patterns instead of reinventing them.

Key Points:

  • Split application flow (build -> test -> push -> deploy) from infrastructure flow (plan -> apply); never share a job between them
  • Tag every image with the immutable git SHA, never :latest, so the running revision is always traceable and rollbacks are deterministic
  • Container Registry tokens are shown once at creation and are deleted on expiry; store them as CI secrets and pull images with the same token-based credentials
  • Run terraform plan on PRs and terraform apply on merge, with shared S3-compatible state in Object Storage and IONOS_TOKEN as a secret
  • Roll back applications with kubectl rollout undo and infrastructure by reverting the commit and re-applying

Important Terminology:

  • Immutable tag: A container image tag derived from the git SHA that never changes, linking each running pod to the exact commit that built it.
  • Rolling update: The default Kubernetes Deployment strategy that replaces pods incrementally so the service stays available during a deploy.
  • Plan on PR: The Terraform CI pattern of running terraform plan on pull requests and posting the diff for review before any apply.
  • Remote state backend: A shared Terraform state store (S3-compatible Object Storage on IONOS) that lets every CI run read and write the same state with locking.
  • imagePullSecret: A Kubernetes Secret holding Container Registry token credentials so the cluster can pull private images.

Next Steps

Continue Learning: Unit 3.4: Knowledge Check - Containers and CI/CD

Related Topics: