VOOZH about

URL: https://dev.to/rmcampos/deploying-to-k8s-with-terraform-using-gh-actions-2nai

⇱ Deploying to k8s with Terraform using GH Actions - DEV Community


Hi all. This article aims to give you the happy path to get your service (or services) up and running on a kubernetes-managed cluster.

Disclaimer

I expect you already have a running k3s or kubernetes environment with direct access, such as SSH, for troubleshooting and reviewing changes after applying them. If you're completely new to Kubernetes, please take a moment to learn more about it, because this article will not provide the steps for a full beginner.

Also, make sure you already have a Dockerfile for your app.

Setup & workflows

Account required:

  • GitHub - Free
  • Cloudflare R2 - Free tier (credit card required)
  • VPS

We will:

  • Create yml files for the Workflows on GitHub
  • Setup secrets and environments on GitHub
  • Create terraform files for deployment

Workflows

  • PR triggers deploy to staging or dev
  • Merges and pushes to main triggers deploy to prod

In short, a workflow would be something like

  • Build a Docker image
  • Push it to a registry (here I use GHCR, GitHub Container Registry)
  • Trigger deployment using Terraform
  • Save tfstate file on Cloudflare R2

This provides:

  • Entire automated workflows, from PR to deploy
  • Option to prevent deploy/skip via approval gate
  • No manual steps (other than the approval)

Hands-on

1 - Building on GitHub Actions for every PR

Let's get started! This is the first step of the workflow. With this setup, every PR will trigger the Docker image build and tag it with candidate and pr-<number>. In the DevOps best practices land, you should not rebuild on merge, but instead, use the same image built for testing. That's what we'll do.

Create a ci-pr.yml file under .github/workflows on your repo, using the following content:

name: Pull Request CI

on:
 workflow_dispatch:
 pull_request:
 types: [opened, synchronize, reopened]
 branches:
 - 'main'

jobs:
 run-checks:
 name: Checks
 runs-on: ubuntu-latest
 permissions:
 contents: read

 steps:
 - name: Checkout code
 uses: actions/checkout@v6
 with:
 fetch-depth: 0

 - name: Set up Java
 uses: actions/setup-java@v4
 with:
 distribution: 'temurin'
 java-version: '25'
 cache: 'maven'
 cache-dependency-path: 'server/pom.xml'

 - name: Run Check Style
 working-directory: ./server
 run: ./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false

 - name: Run build
 working-directory: ./server
 run: ./mvnw --no-transfer-progress clean compile -DskipTests

 - name: Run tests
 working-directory: ./server
 run: ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml

 build-and-push:
 name: Build & Push
 runs-on: ubuntu-latest
 needs: ["run-checks"]
 permissions:
 contents: read
 packages: write

 steps:
 - name: Checkout code
 uses: actions/checkout@v6
 with:
 fetch-depth: 0

 - name: Set lowercase repo name
 id: repo
 run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT

 - name: Set up Java
 uses: actions/setup-java@v4
 with:
 distribution: 'temurin'
 java-version: '25'
 cache: 'maven'
 cache-dependency-path: 'server/pom.xml'

 - name: Log in to GitHub Container Registry
 uses: docker/login-action@v3
 with:
 registry: ghcr.io
 username: ${{ github.actor }}
 password: ${{ secrets.GITHUB_TOKEN }}

 - name: Cache Buildpack layers
 uses: actions/cache@v4
 with:
 path: |
 ~/.cache/reproducible-builds
 key: ${{ runner.os }}-buildpack-${{ hashFiles('server/pom.xml') }}
 restore-keys: |
 ${{ runner.os }}-buildpack-

 - name: Build Docker image with Spring Boot
 working-directory: ./server
 run: |
 ./mvnw -Pnative -DskipTests spring-boot:build-image \
 -Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
 -Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest

 - name: Tag and push Docker image
 run: |
 docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
 docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}
 docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
 docker push ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}

At this point, we have a Docker image built and ready to be deployed. The image will show up on your repo packages, or DockerHub registry.

2 - Deploying PR to staging

Let's move forward with the second step - the CD, continuous integration - getting this image deployed on staging/testing environment.

For this step, we'll use Terraform and Cloudflare R2. You also need to set secrets on GitHub, if you're app needs, and R2 access key id and secrets. If you need help with this step, please add a comment and I'll help you out. The workflow will search for a main.tf file onder terraform-stg directory. If you need one for reference, please see this one: https://github.com/RMCampos/tasknote/blob/main/terraform-stg/main.tf feel free to copy and make changes.

Also note the KubeConfig data, encoded in Base64, that should be added to github secrets (KUBECONFIG_DATA), from your VPS config file.

Now create a cd-pr.yml file under .github/workflows with the following content:

name: Pull Request CD

on:
 workflow_dispatch:
 workflow_run:
 workflows: [ "PullRequestCI" ]
 types: [ completed ]

jobs:
 terraform-plan-stg:
 name: Plan changs to staging
 runs-on: ubuntu-latest
 outputs:
 no_changes: ${{ steps.check-changes.outputs.no_changes }}
 permissions:
 contents: read
 steps:
 - name: Checkout code
 uses: actions/checkout@v4
 with:
 fetch-depth: 0

 - name: Setup Terraform
 uses: hashicorp/setup-terraform@v3

 - name: Setup kubectl
 uses: azure/setup-kubectl@v4

 - name: Setup Kubeconfig
 run: |
 mkdir -p ~/.kube
 echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
 chmod 600 ~/.kube/config

 - name: Validate cluster access
 run: |
 kubectl cluster-info
 kubectl get namespace tasknote-stg

 - name: Determine deployment values
 id: deploy-vars
 run: |
 docker_image="ghcr.io/rmcampos/tasknote/api:candidate"

 echo "docker_image=$docker_image" >> "$GITHUB_OUTPUT"

 - name: Terraform Fmt -check -diff
 working-directory: terraform-stg
 run: terraform fmt -check -diff

 - name: Terraform Init
 working-directory: terraform-stg
 env:
 AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
 AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
 run: terraform init -input=false

 - name: Terraform Validate
 working-directory: terraform-stg
 run: terraform validate

 - name: Terraform Plan
 id: check-changes
 working-directory: terraform-stg
 env:
 AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
 AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
 run: |
 timeout 1m terraform plan -input=false -out=tfplan \
 -var="db_user=${{ secrets.DB_USER }}" \
 -var="db_password=${{ secrets.DB_PASSWORD }}" \
 -var="db_name=${{ secrets.DB_NAME }}" \
 -var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \
 -var="mailgun_apikey=${{ secrets.MAILGUN_API_KEY }}" \
 -var="docker_image=${{ steps.deploy-vars.outputs.docker_image }}" \
 -var="deploy_version=${{ github.run_id }}"
 terraform show -json tfplan > tfplan.json
 if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
 echo "no_changes=true" >> "$GITHUB_OUTPUT"
 echo "No changes to apply."
 exit 0
 else
 echo "Changes detected. Proceeding with apply"
 echo "no_changes=false" >> "$GITHUB_OUTPUT"
 fi

 - name: Upload plan artifact
 uses: actions/upload-artifact@v4
 with:
 name: tfplan
 path: terraform-stg/tfplan

 terraform-apply:
 runs-on: ubuntu-latest
 needs: terraform-plan-stg
 if: needs.terraform-plan-stg.outputs.no_changes == 'false'
 environment:
 name: staging
 url: https://<your-url-here>
 permissions:
 contents: read
 steps:
 - name: Checkout code
 uses: actions/checkout@v6

 - name: Setup Terraform
 uses: hashicorp/setup-terraform@v3

 - name: Download plan artifact
 uses: actions/download-artifact@v4
 with:
 name: tfplan
 path: terraform-stg

 - name: Setup Kubeconfig
 run: |
 mkdir -p ~/.kube
 echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
 chmod 600 ~/.kube/config

 - name: Terraform Init
 working-directory: terraform-stg
 env:
 AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
 AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
 run: terraform init -input=false

 - name: Terraform Apply
 working-directory: terraform-stg
 env:
 AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
 AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
 run: timeout 1m terraform apply tfplan

This workflow will run terraform plan and deploy the new image to be tested and confirmed the changes are safe to go to prod.

Next, we want to have this image promoted and deployed to prod, when the PR gets merged.

3 - Promoting PR image and deploying

For this step we need a new workflow file. Go ahead and create the ci-main.yml file with the following content:

name: Main CI

on:
 workflow_dispatch:
 push:
 branches:
 - main

jobs:
 build-and-push:
 name: Build & Push
 runs-on: ubuntu-latest
 permissions:
 contents: write
 packages: write

 steps:
 - name: Checkout code
 uses: actions/checkout@v6
 with:
 fetch-depth: 0

 - name: Set lowercase repo name
 id: repo
 run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT

 - name: Set up Java
 uses: actions/setup-java@v4
 with:
 distribution: 'temurin'
 java-version: '25'
 cache: 'maven'

 - name: Increment version in pom.xml
 id: version
 working-directory: ./server
 run: |
 # Extract current version from pom.xml
 CURRENT_VERSION=$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout)
 echo "Current version: ${CURRENT_VERSION}"

 # Increment version
 NEW_VERSION=$((CURRENT_VERSION + 1))
 echo "New version: ${NEW_VERSION}"

 # Update pom.xml with new version
 ./mvnw versions:set -DnewVersion=${NEW_VERSION} -DgenerateBackupFiles=false -q

 # Output for later steps
 echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT

 - name: Commit version bump
 run: |
 git config user.name "github-actions[bot]"
 git config user.email "github-actions[bot]@users.noreply.github.com"
 git add server/pom.xml
 git commit -m "chore: bump api version to ${{ steps.version.outputs.version }} [skip ci]"
 git push

 - name: Log in to GitHub Container Registry
 uses: docker/login-action@v3
 with:
 registry: ghcr.io
 username: ${{ github.actor }}
 password: ${{ secrets.GITHUB_TOKEN }}

 - name: Set up Docker Buildx
 uses: docker/setup-buildx-action@v3

 - name: Find PR number
 id: find_pr
 env:
 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 run: |
 PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state merged --json number --jq '.[0].number')
 if [ -z "$PR_NUMBER" ]; then
 echo "No merged PR found for this commit. Falling back to 'candidate' tag."
 PR_NUMBER="candidate"
 else
 PR_NUMBER="pr-${PR_NUMBER}"
 fi
 echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT

 - name: Promote Docker image
 run: |
 docker buildx imagetools create \
 --tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
 --tag ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.version.outputs.version }} \
 ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.find_pr.outputs.tag }}

 - name: Create and push Git tag
 run: |
 git config user.name "github-actions[bot]"
 git config user.email "github-actions[bot]@users.noreply.github.com"
 git tag -a api-v${{ steps.version.outputs.version }} -m "Release API v${{ steps.version.outputs.version }}"
 git push origin api-v${{ steps.version.outputs.version }}

By now the merged PR will produce a new tag by retagging the docker image built in the PR. All this workflow does is to promote the image, and for my case, update my Java app version. Feel free to drop these Java steps and adjust to your case.

Now the last step is to get the promoted image deployed to prod, optionally.

4 - Deploying to prod

This workflow is the one responsible to pushing our final docker image to production, using Terraform, and zero down time, deploying to a Kubernetes cluster. In my case, I have a k3s cluster running on a VPS, but this works for several similar scenarios.

Go ahead and create the cd-main.yml file with the final step, using the following content:

For this workflow, you need to provide a main.tf file inside the terraform folder. Again, if needed, you can use my version as starting point, grab it here: https://github.com/RMCampos/tasknote/blob/main/terraform/main.tf

name: Main CD-Deploy to Prod

on:
 workflow_dispatch:
 inputs:
 docker_image:
 description: "Dockerimagetag(fullimagereference)"
 required: false
 apply:
 description: "Applychangesafterplan"
 required: false
 default: "true"
 workflow_run:
 workflows: [ "MainCI" ]
 types: [ completed ]

jobs:
 terraform-plan:
 if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
 runs-on: ubuntu-latest
 outputs:
 no_changes: ${{ steps.check-changes.outputs.no_changes }}
 permissions:
 contents: read
 steps:
 - name: Checkout code
 uses: actions/checkout@v6
 with:
 fetch-depth: 0

 - name: Setup Terraform
 uses: hashicorp/setup-terraform@v3

 - name: Setup kubectl
 uses: azure/setup-kubectl@v4

 - name: Setup Kubeconfig
 run: |
 mkdir -p ~/.kube
 echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
 chmod 600 ~/.kube/config

 - name: Validate cluster access
 run: |
 kubectl cluster-info
 kubectl get namespace tasknote

 - name: Determine deployment values
 id: deploy-vars
 run: |
 docker_image="${{ github.event.inputs.docker_image }}"

 latest_tag="$(git tag --list 'api-v*' | sort -V | tail -n1)"

 if [ -z "$latest_tag" ]; then
 echo "No tag found matching api-v*" >&2
 exit 1
 fi

 if [ -z "$docker_image" ]; then
 docker_image="ghcr.io/rmcampos/tasknote/api:$latest_tag"
 fi

 echo "Resolved docker_image=$docker_image"

 echo "docker_image=$docker_image" >> "$GITHUB_OUTPUT"

 - name: Terraform Fmt -check -diff
 working-directory: terraform
 run: terraform fmt -check -diff

 - name: Terraform Init
 working-directory: terraform
 env:
 AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
 AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
 run: terraform init -input=false

 - name: Terraform Validate
 working-directory: terraform
 run: terraform validate

 - name: Terraform Plan
 id: check-changes
 working-directory: terraform
 env:
 AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
 AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
 run: |
 timeout 1m terraform plan -input=false -out=tfplan \
 -var="db_user=${{ secrets.DB_USER }}" \
 -var="db_password=${{ secrets.DB_PASSWORD }}" \
 -var="db_name=${{ secrets.DB_NAME }}" \
 -var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \
 -var="mailgun_apikey=${{ secrets.MAILGUN_API_KEY }}" \
 -var="docker_image=${{ steps.deploy-vars.outputs.docker_image }}" \
 terraform show -json tfplan > tfplan.json
 if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
 echo "no_changes=true" >> "$GITHUB_OUTPUT"
 echo "No changes to apply."
 exit 0
 else
 echo "Changes detected. Proceeding with apply"
 echo "no_changes=false" >> "$GITHUB_OUTPUT"
 fi

 - name: Upload plan artifact
 uses: actions/upload-artifact@v4
 with:
 name: tfplan
 path: terraform/tfplan

 terraform-apply:
 runs-on: ubuntu-latest
 needs: terraform-plan
 if: >
 (github.event_name == 'push' || github.event_name == 'workflow_run' || inputs.apply == 'true')
 && needs.terraform-plan.outputs.no_changes == 'false'
 environment:
 name: production
 url: <your-url-here>
 permissions:
 contents: read
 steps:
 - name: Checkout code
 uses: actions/checkout@v6

 - name: Setup Terraform
 uses: hashicorp/setup-terraform@v3

 - name: Download plan artifact
 uses: actions/download-artifact@v4
 with:
 name: tfplan
 path: terraform

 - name: Setup Kubeconfig
 run: |
 mkdir -p ~/.kube
 echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
 chmod 600 ~/.kube/config

 - name: Terraform Init
 working-directory: terraform
 env:
 AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
 AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
 run: terraform init -input=false

 - name: Terraform Apply
 working-directory: terraform
 env:
 AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
 AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
 run: timeout 1m terraform apply tfplan

5 - Trobleshooting

Here are quick items to double-check if you get errors:

  • Secrets needed by your app added to repo secrets on GitHub Settings;
  • Kube config data added to repo secrets on GitHub Settings;
  • Cloudflare R2 Access Key ID and Secret Access Key added to repo secrets on GitHub Settings
  • Namespaces are well set and define in the workflows and in the cluster;

Here's how you can generate the Kube config data encoded in Base64:

# Run this on your VPS terminal:
base64 path-to-kube-config-file | tr -d '\n' > kbdata.tx

# For example:
base64 ~/.kube/config | tr -d '\n' > kbdata.tx

I know it's a lot. But feel free to get in touch on Social Media or ask questions in the comments.