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: [ "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
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: "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
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.
Top comments (0)