Tools: Deploying to k8s with Terraform using GH Actions (2026)

Tools: Deploying to k8s with Terraform using GH Actions (2026)

Disclaimer

Setup & workflows

Hands-on

1 - Building on GitHub Actions for every PR

2 - Deploying PR to staging

3 - Promoting PR image and deploying

4 - Deploying to prod

5 - Trobleshooting 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. 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. In short, a workflow would be something like 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: 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. 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: 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. For this step we need a new workflow file. Go ahead and create the ci-main.yml file with the following content: 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. 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 Here are quick items to double-check if you get errors: Here's how you can generate the Kube config data encoded in Base64: I know it's a lot. But feel free to get in touch on Social Media or ask questions in the comments. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ 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: -weight: 500;">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: | -weight: 500;">docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:candidate -weight: 500;">docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }} -weight: 500;">docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate -weight: 500;">docker push ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }} 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: -weight: 500;">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: | -weight: 500;">docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:candidate -weight: 500;">docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }} -weight: 500;">docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate -weight: 500;">docker push ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }} 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: -weight: 500;">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: | -weight: 500;">docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:candidate -weight: 500;">docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }} -weight: 500;">docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate -weight: 500;">docker push ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }} 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 -weight: 500;">kubectl uses: azure/setup--weight: 500;">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: | -weight: 500;">kubectl cluster-info -weight: 500;">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 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 -weight: 500;">kubectl uses: azure/setup--weight: 500;">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: | -weight: 500;">kubectl cluster-info -weight: 500;">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 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 -weight: 500;">kubectl uses: azure/setup--weight: 500;">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: | -weight: 500;">kubectl cluster-info -weight: 500;">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 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: | -weight: 500;">git config user.name "github-actions[bot]" -weight: 500;">git config user.email "github-actions[bot]@users.noreply.github.com" -weight: 500;">git add server/pom.xml -weight: 500;">git commit -m "chore: bump api version to ${{ steps.version.outputs.version }} [skip ci]" -weight: 500;">git push - name: Log in to GitHub Container Registry uses: -weight: 500;">docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: -weight: 500;">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: | -weight: 500;">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: | -weight: 500;">git config user.name "github-actions[bot]" -weight: 500;">git config user.email "github-actions[bot]@users.noreply.github.com" -weight: 500;">git tag -a api-v${{ steps.version.outputs.version }} -m "Release API v${{ steps.version.outputs.version }}" -weight: 500;">git push origin api-v${{ steps.version.outputs.version }} 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: | -weight: 500;">git config user.name "github-actions[bot]" -weight: 500;">git config user.email "github-actions[bot]@users.noreply.github.com" -weight: 500;">git add server/pom.xml -weight: 500;">git commit -m "chore: bump api version to ${{ steps.version.outputs.version }} [skip ci]" -weight: 500;">git push - name: Log in to GitHub Container Registry uses: -weight: 500;">docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: -weight: 500;">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: | -weight: 500;">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: | -weight: 500;">git config user.name "github-actions[bot]" -weight: 500;">git config user.email "github-actions[bot]@users.noreply.github.com" -weight: 500;">git tag -a api-v${{ steps.version.outputs.version }} -m "Release API v${{ steps.version.outputs.version }}" -weight: 500;">git push origin api-v${{ steps.version.outputs.version }} 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: | -weight: 500;">git config user.name "github-actions[bot]" -weight: 500;">git config user.email "github-actions[bot]@users.noreply.github.com" -weight: 500;">git add server/pom.xml -weight: 500;">git commit -m "chore: bump api version to ${{ steps.version.outputs.version }} [skip ci]" -weight: 500;">git push - name: Log in to GitHub Container Registry uses: -weight: 500;">docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: -weight: 500;">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: | -weight: 500;">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: | -weight: 500;">git config user.name "github-actions[bot]" -weight: 500;">git config user.email "github-actions[bot]@users.noreply.github.com" -weight: 500;">git tag -a api-v${{ steps.version.outputs.version }} -m "Release API v${{ steps.version.outputs.version }}" -weight: 500;">git push origin api-v${{ steps.version.outputs.version }} 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 -weight: 500;">kubectl uses: azure/setup--weight: 500;">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: | -weight: 500;">kubectl cluster-info -weight: 500;">kubectl get namespace tasknote - name: Determine deployment values id: deploy-vars run: | docker_image="${{ github.event.inputs.docker_image }}" latest_tag="$(-weight: 500;">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 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 -weight: 500;">kubectl uses: azure/setup--weight: 500;">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: | -weight: 500;">kubectl cluster-info -weight: 500;">kubectl get namespace tasknote - name: Determine deployment values id: deploy-vars run: | docker_image="${{ github.event.inputs.docker_image }}" latest_tag="$(-weight: 500;">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 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 -weight: 500;">kubectl uses: azure/setup--weight: 500;">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: | -weight: 500;">kubectl cluster-info -weight: 500;">kubectl get namespace tasknote - name: Determine deployment values id: deploy-vars run: | docker_image="${{ github.event.inputs.docker_image }}" latest_tag="$(-weight: 500;">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 # 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 # 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 # 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 - GitHub - Free - Cloudflare R2 - Free tier (credit card required) - Create yml files for the Workflows on GitHub - Setup secrets and environments on GitHub - Create terraform files for deployment - PR triggers deploy to staging or dev - Merges and pushes to main triggers deploy to prod - 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 - Entire automated workflows, from PR to deploy - Option to prevent deploy/skip via approval gate - No manual steps (other than the approval) - 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;