CI/CD Pipeline and Kubernetes Manifests
Wire up the whole road from git push to running pods: a GitHub Actions pipeline
that tests, lints, builds, and pushes a Docker image, plus a complete set of
Kubernetes manifests (Deployment, Service, ConfigMap, Secret, Ingress, HPA) that
run a Spring Boot Kotlin service in production with proper JVM-aware health probes
and auto-scaling.
If you’ve shipped Node or Go services, the moving parts are familiar — a CI
workflow, a container registry, a reverse proxy, an autoscaler. The twist here is
making the manifests respect the JVM: the startup probe gives the JVM time to warm
up, and JAVA_OPTS tells the heap to live inside the container’s memory limit.
Requirements
Section titled “Requirements”- A GitHub Actions workflow that tests and lints on every push/PR, with a PostgreSQL service container for integration tests.
- A release workflow that builds a fat JAR, pushes a Docker image to GHCR, and
deploys to Kubernetes on a
v*git tag. - Kubernetes manifests:
Deployment,Service,ConfigMap,Secret,Ingress, and anHorizontalPodAutoscaler. - Health probes (
liveness/readiness/startup) tuned for JVM startup time. - Environment-based configuration through Spring profiles, with sensitive vs
non-sensitive config split between
SecretandConfigMap.
The worked solution
Section titled “The worked solution”The repo carries two GitHub Actions workflows and a folder of six Kubernetes
manifests. The Spring Boot app itself is conventional (the build is the
bootJar-producing build.gradle.kts below) — the interesting part is everything
around the code.
Directorycicd-k8s/
Directory.github/
Directoryworkflows/
- ci.yml test + lint on every push/PR
- release.yml build + push + deploy on a v* tag
Directoryk8s/
- deployment.yaml the app Deployment, probes, JAVA_OPTS
- service.yaml ClusterIP service
- configmap.yaml non-sensitive config
- secret.yaml sensitive data (template)
- ingress.yaml nginx ingress + TLS
- hpa.yaml HorizontalPodAutoscaler
- build.gradle.kts Spring Boot build, produces app.jar
- settings.gradle.kts
- Dockerfile
- docker-compose.yml
The pipeline at a glance
Section titled “The pipeline at a glance”Two workflows, two triggers. ci.yml runs on every push and PR and guards
correctness; release.yml only fires on a v* tag and is what actually ships.
flowchart LR Dev["git push / PR"] --> CI Tag["git tag v*"] --> REL subgraph CI["ci.yml"] direction TB T["test job (+ Postgres service)"] --> B["build job (bootJar)"] end subgraph REL["release.yml"] direction TB J["build app.jar"] --> D["build + push image to GHCR"] D --> K["kubectl set image -> rollout"] end K --> Cluster["Kubernetes cluster"]
build.gradle.kts
Section titled “build.gradle.kts”A standard Spring Boot + Kotlin build. Two details matter for deployment: the
version is read from a Gradle property so the release workflow can stamp it
(-Pversion=...), and bootJar is renamed to a stable app.jar so the
Dockerfile and the JAR artifact upload don’t have to track a version in the
filename.
plugins { id("org.springframework.boot") version "3.3.0" id("io.spring.dependency-management") version "1.1.5" kotlin("jvm") version "2.0.0" kotlin("plugin.spring") version "2.0.0" kotlin("plugin.jpa") version "2.0.0"}
group = "com.example"version = project.findProperty("version")?.toString() ?: "0.0.1-SNAPSHOT"
repositories { mavenCentral()}
dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("com.h2database:h2")}
tasks.bootJar { archiveFileName.set("app.jar")}
kotlin { jvmToolchain(21)}The spring-boot-starter-actuator dependency is load-bearing here — it’s what
exposes the /actuator/health/liveness and /actuator/health/readiness
endpoints that the Kubernetes probes hit.
CI workflow — test and build
Section titled “CI workflow — test and build”On every push and PR this runs the test job. The services: block spins up a
PostgreSQL container alongside the job (the GitHub Actions equivalent of a
docker compose sidecar for tests) with a health check so the test step doesn’t
start until Postgres is ready. The build job needs: test and only runs on a
push, not on PRs from forks.
A note on the cache key: cache-read-only is set to the expression
${{ github.ref != 'refs/heads/main' }}, so branch builds read the Gradle cache
but only main is allowed to write to it — keeping the shared cache clean.
name: CI
on: push: branches: [main, develop] pull_request: branches: [main]
jobs: test: runs-on: ubuntu-latest
services: postgres: image: postgres:16-alpine env: POSTGRES_DB: testdb POSTGRES_USER: test POSTGRES_PASSWORD: test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps: - uses: actions/checkout@v4
- name: Set up JDK 21 uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin'
- name: Setup Gradle uses: gradle/actions/setup-gradle@v4 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Run tests run: ./gradlew test env: DATABASE_URL: jdbc:postgresql://localhost:5432/testdb DATABASE_USER: test DATABASE_PASSWORD: test
- name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results path: build/reports/tests/ retention-days: 7
build: needs: test runs-on: ubuntu-latest if: github.event_name == 'push'
steps: - uses: actions/checkout@v4
- name: Set up JDK 21 uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin'
- name: Setup Gradle uses: gradle/actions/setup-gradle@v4
- name: Build JAR run: ./gradlew bootJar
- name: Upload JAR artifact uses: actions/upload-artifact@v4 with: name: app-jar path: build/libs/app.jar retention-days: 7Release workflow — build, push, deploy
Section titled “Release workflow — build, push, deploy”This is the deploy half. It triggers only on a v* tag and runs two jobs in
sequence: release builds and publishes, then deploy rolls it out.
- The tag is parsed into a
VERSIONenv var (${GITHUB_REF#refs/tags/v}), which is fed to Gradle as-Pversion=...so the artifact carries the release version. docker/metadata-actiongenerates image tags from the semver tag (full version,major.minor, and ashatag) so consumers can pin at the precision they want.- The build-push step uses GitHub Actions’ build cache (
cache-from: type=gha,cache-to: type=gha,mode=max) so Docker layers are reused across runs. - The
deployjob uses aproductionenvironment (so you can gate it behind a required reviewer) and ships withkubectl set imagefollowed by akubectl rollout statusthat fails the job if the rollout doesn’t complete in 300s.
The registry login uses the built-in ${{ secrets.GITHUB_TOKEN }} — no extra
secret to manage for pushing to GHCR.
name: Release
on: push: tags: - 'v*'
env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }}
jobs: release: runs-on: ubuntu-latest permissions: contents: write packages: write
steps: - uses: actions/checkout@v4
- name: Set up JDK 21 uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin'
- name: Setup Gradle uses: gradle/actions/setup-gradle@v4
- name: Extract version from tag run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Build JAR with version run: ./gradlew bootJar -Pversion=${{ env.VERSION }}
- name: Set up Docker Buildx uses: docker/setup-buildx-action@v3
- name: Log in to container registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha
- name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max
- name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: build/libs/app.jar generate_release_notes: true
deploy: needs: release runs-on: ubuntu-latest environment: production
steps: - uses: actions/checkout@v4
- name: Set up kubectl uses: azure/setup-kubectl@v4
- name: Set image tag run: | VERSION=${GITHUB_REF#refs/tags/v} echo "IMAGE_TAG=${VERSION}" >> $GITHUB_ENV
- name: Deploy to Kubernetes run: | kubectl set image deployment/task-api \ app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} \ --namespace=production kubectl rollout status deployment/task-api \ --namespace=production \ --timeout=300sThe Kubernetes manifests
Section titled “The Kubernetes manifests”Six objects make up the running service. Here’s how they fit together: traffic
enters through the Ingress, hits the Service, which load-balances across the
Deployment’s pods; each pod’s config comes from the ConfigMap and Secret,
and the HPA adjusts the replica count based on load.
flowchart TB User["Client"] -->|"HTTPS task-api.example.com"| Ing["Ingress (nginx + TLS)"] Ing -->|"port 80"| Svc["Service (ClusterIP)"] Svc -->|"targetPort http 8080"| Dep subgraph Dep["Deployment task-api"] P1["Pod"] P2["Pod"] end CM["ConfigMap"] -.->|"server-port, log-level"| Dep Sec["Secret"] -.->|"db url / user / password"| Dep HPA["HorizontalPodAutoscaler"] -.->|"scales 2..10 on CPU/mem"| Dep
Deployment — the centerpiece
Section titled “Deployment — the centerpiece”The Deployment is where the JVM-specific care lives. A few things worth calling
out:
- Rolling update with
maxUnavailable: 0keeps full capacity during a rollout — new pods come up before old ones go down. - Three probes, three jobs. The
startupProbe(lines 94-100) hits/actuator/healthand allows up to30 × 5s = 150sfor the JVM to boot before the liveness probe even starts — this is the JVM-specific bit Node/Go don’t need.livenessProberestarts a wedged process;readinessProbepulls a pod out of the Service rotation when it can’t serve traffic. JAVA_OPTS(lines 61-69) make the heap container-aware:-XX:MaxRAMPercentage=75.0and-XX:+UseContainerSupportsize the heap from the cgroup memory limit (not the host’s RAM), and-XX:+ExitOnOutOfMemoryErrormakes an OOM kill the process so Kubernetes restarts it cleanly.- Secrets and config are injected as env vars via
secretKeyRefandconfigMapKeyRef— Spring picks them up asSPRING_DATASOURCE_*andSERVER_PORT. - A non-root
securityContext, a writable/tmpemptyDir(so a read-only rootfs still has a scratch dir for heap dumps), and a 60sterminationGracePeriodSecondsround it out.
apiVersion: apps/v1kind: Deploymentmetadata: name: task-api labels: app: task-apispec: replicas: 2 selector: matchLabels: app: task-api strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: metadata: labels: app: task-api annotations: prometheus.io/scrape: "true" prometheus.io/port: "8080" prometheus.io/path: "/actuator/prometheus" spec: serviceAccountName: task-api securityContext: runAsNonRoot: true runAsUser: 1000 fsGroup: 1000 containers: - name: app image: ghcr.io/example/task-api:latest ports: - containerPort: 8080 name: http protocol: TCP env: - name: SPRING_PROFILES_ACTIVE value: "prod" - name: SPRING_DATASOURCE_URL valueFrom: secretKeyRef: name: task-api-secrets key: database-url - name: SPRING_DATASOURCE_USERNAME valueFrom: secretKeyRef: name: task-api-secrets key: database-user - name: SPRING_DATASOURCE_PASSWORD valueFrom: secretKeyRef: name: task-api-secrets key: database-password - name: SERVER_PORT valueFrom: configMapKeyRef: name: task-api-config key: server-port - name: JAVA_OPTS value: >- -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -XX:+UseContainerSupport -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof resources: requests: cpu: "250m" memory: "512Mi" limits: cpu: "1000m" memory: "1Gi" # Liveness: is the process alive? livenessProbe: httpGet: path: /actuator/health/liveness port: http initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 3 # Readiness: can it serve traffic? readinessProbe: httpGet: path: /actuator/health/readiness port: http initialDelaySeconds: 15 periodSeconds: 5 failureThreshold: 3 # Startup: give JVM time to start startupProbe: httpGet: path: /actuator/health port: http initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 30 volumeMounts: - name: tmp mountPath: /tmp volumes: - name: tmp emptyDir: {} terminationGracePeriodSeconds: 60Service and Ingress
Section titled “Service and Ingress”The Service is a plain ClusterIP: stable internal name task-api, listening
on port 80, forwarding to the pods’ named http port (8080). Using the port
name http as targetPort means the Service keeps working even if you change
the container port number later.
apiVersion: v1kind: Servicemetadata: name: task-api labels: app: task-apispec: type: ClusterIP selector: app: task-api ports: - name: http port: 80 targetPort: http protocol: TCPThe Ingress is the public entrypoint: it terminates TLS (with a cert in
task-api-tls), routes the host task-api.example.com to the Service, and tunes
two nginx behaviours — a 10 MB body-size cap and a 60s read timeout.
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: task-api annotations: nginx.ingress.kubernetes.io/proxy-body-size: "10m" nginx.ingress.kubernetes.io/proxy-read-timeout: "60"spec: ingressClassName: nginx rules: - host: task-api.example.com http: paths: - path: / pathType: Prefix backend: service: name: task-api port: name: http tls: - hosts: - task-api.example.com secretName: task-api-tlsConfigMap and Secret
Section titled “ConfigMap and Secret”The split is the whole point: non-sensitive knobs (server-port, log-level) go
in a ConfigMap; credentials go in a Secret. Both are surfaced to the pod as
env vars (see the Deployment above), so the app never reads a config file off disk.
apiVersion: v1kind: ConfigMapmetadata: name: task-api-configdata: server-port: "8080" log-level: "INFO"The committed Secret is a template with base64-encoded example values only.
Note that base64 is encoding, not encryption — anyone with read access to the
Secret can decode it, so the real values should come from kubectl create secret
or a proper secrets manager, never from a committed file.
# IMPORTANT: This is a template. In production, use:# kubectl create secret generic task-api-secrets \# --from-literal=database-url="jdbc:postgresql://db:5432/myapp" \# --from-literal=database-user="app_user" \# --from-literal=database-password="<real-password>"## Or better: use External Secrets Operator, Sealed Secrets, or Vault.# Never commit real secrets to version control.
apiVersion: v1kind: Secretmetadata: name: task-api-secretstype: Opaque# These are base64-encoded EXAMPLE values onlydata: database-url: amRiYzpwb3N0Z3Jlc3FsOi8vZGI6NTQzMi90YXNrZGI= database-user: cG9zdGdyZXM= database-password: cG9zdGdyZXM=HorizontalPodAutoscaler
Section titled “HorizontalPodAutoscaler”The HPA keeps between 2 and 10 replicas, scaling on both CPU (target 70%) and
memory (target 80%) utilization. The behavior block makes scaling asymmetric —
fast to scale up (add up to 2 pods/min after a 60s window) and slow to scale down
(remove 1 pod every 2 min after a 5-min window) so a brief traffic dip doesn’t
churn pods.
apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: task-apispec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: task-api minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Resource resource: name: memory target: type: Utilization averageUtilization: 80 behavior: scaleUp: stabilizationWindowSeconds: 60 policies: - type: Pods value: 2 periodSeconds: 60 scaleDown: stabilizationWindowSeconds: 300 policies: - type: Pods value: 1 periodSeconds: 120Run and deploy
Section titled “Run and deploy”Locally you can run the app with Docker Compose; in a cluster you apply the manifests and watch the rollout.
-
Run the app locally with its dependencies:
Terminal window docker compose up --build -
Run the test suite (the same
./gradlew testCI runs):Terminal window ./gradlew test -
Create a namespace and apply all the manifests:
Terminal window kubectl create namespace myappkubectl apply -f k8s/ -n myapp -
Watch the rollout complete:
Terminal window kubectl rollout status deployment/task-api -n myappkubectl get pods -n myapp -
Port-forward the Service to test it locally without the Ingress:
Terminal window kubectl port-forward svc/task-api 8080:80 -n myapp