DEV Community

Ricardo Campos
Ricardo Campos

Posted on

Deploying to k8s with Terraform using GH Actions

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 }}
Enter fullscreen mode Exit fullscreen mode

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: [ "Pull Request CI" ]
    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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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: "Docker image tag (full image reference)"
        required: false
      apply:
        description: "Apply changes after plan"
        required: false
        default: "true"
  workflow_run:
    workflows: [ "Main CI" ]
    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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)