A Practical CI/CD Setup for Go Services with GitHub Actions

February 8, 2026

Why Automate Deployments at All?

Automating deployments is one of the most effective ways to build real DevOps experience.

Without automation, deployments waste time and rely on you remembering every step required to build, test, containerize, and ship code to a VPS.

There are many free platforms to run your CI/CD pipeline, so the barrier for setting up automated deployments, even for personal projects, is close to nothing.

High-Level Pipeline Overview

When automating your webserver deployments, then these are the basic steps that your pipeline should be doing:

  1. Trigger a deployment (push code or click a button)
  2. Build and test a Go service
  3. Build a Docker image
  4. Push it to a registry
  5. Deploy it to a VPS
  6. Verify the service is running

We’ll implement a pipeline with these steps using GitHub Actions, but first we have to setup your repository for the CI/CD pipeline.

Note, this guide assumes that you already have a VPS, Go based webserver and a dockerfile for it. A more production ready webserver would be expected to use a load balancer with dockerstack, but this guide will just cover basics.

Repository Structure

If your code is already hosted on GitHub, then setting up your first CI/CD pipeline is as simple as creating a deploy.yaml file under the .github/workflows/ directory.

. ├── .github/ │ └── workflows/ │ └── deploy.yml ├── cmd/ │ └── api/ │ └── main.go ├── internal/ │ └── server/ ├── Dockerfile ├── go.mod └── go.sum

Setting Up GitHub Actions

Creating the Workflow

GitHub Actions workflows live in the .github/workflows/ directory. This guide will use a deploy.yaml file but you can rename the file to any other name.

Branch-Based and Manual Triggers

We’ll support:

  • automatic deploys on main
  • manual deploys via the GitHub UI
name: Deploy Go Service on: push: branches: - main workflow_dispatch: env: APP_NAME: myapp SERVER_PORT: 3000

workflow_dispatch gives you a manual “Run workflow” button within the GitHub website, which is invaluable for:

  • hotfixes
  • infra changes
  • re-deploying without code changes

We define environment variables at the workflow level so they can be reused across all jobs. This makes it easy to update values like the app name or port in one place. You can reference these using ${{ env.<variable_name> }} syntax throughout the workflow.

Optionally, if your repository is structured as a monorepo, then you can add a 'paths' option to specify updates to which directory will trigger the pipeline.

on: push: paths: - "server/**"

Core Pipeline Functionality

Let’s break the pipeline into stages. Each major stage can be modeled as its own job, allowing you to gate later steps on earlier ones using the needs option.

1. Build and Test the Go Application

build-and-test: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: "1.22" - name: Download dependencies run: go mod download - name: Run tests run: go test ./...

This ensures:

  • the code builds
  • tests pass
  • broken commits never reach production

2. Creating the Dockerfile

Before we can build in CI, we need a Dockerfile. Here's a multi-stage build for Go that keeps the final image small:

FROM golang:1.22-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ go build -o app ./cmd/api FROM alpine:latest WORKDIR /app COPY --from=builder /app/app . EXPOSE 3000 CMD ["./app"]

The EXPOSE 3000 should match your SERVER_PORT environment variable. This documents which port the container listens on, though the actual port mapping happens during docker run with the -p flag.

This uses a two-stage build: the first stage compiles your Go app, and the second stage creates a minimal runtime image with just the binary.

3. Building and Pushing to GitHub Container Registry

Now we'll automate the build and push process using GitHub Actions. We'll use GitHub Container Registry (GHCR) to keep everything in the GitHub ecosystem.

build-and-push-image: runs-on: ubuntu-latest needs: build-and-test - steps: - name: Checkout repository uses: actions/checkout@v4 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: https://ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v6 with: push: true tags: | ghcr.io/${{ github.repository_owner }}/${{ env.APP_NAME }}:latest ghcr.io/${{ github.repository_owner }}/${{ env.APP_NAME }}:${{ github.sha }}

This job builds the image using your Dockerfile, tags it with both latest and the commit SHA (for versioning), and pushes to GHCR. The GITHUB_TOKEN is automatically available in GitHub Actions, so no manual secret setup is needed for registry authentication.

4. Deploying to a VPS

Before you proceed, make sure you have the following a Linux VPS with Docker installed and SSH access.

To connect your GitHub Actions pipeline with your server, you will need to set the following repository secrets within GitHub's Repository Settings > Security > Secrets and Variables > Actions path:

  • VPS_HOST
  • VPS_USER
  • VPS_SSH_KEY

Once you have added your repository secrets, add a new step for SSH-Based Deployment

- name: Deploy to VPS uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_SSH_KEY }} envs: DATABASE_URL,SERVER_PORT script: | docker pull ghcr.io/${{ github.repository_owner }}/${{ env.APP_NAME }}:${{ github.sha }} docker stop ${{ env.APP_NAME }} || true docker rm ${{ env.APP_NAME }} || true docker run -d \ --name ${{ env.APP_NAME }} \ -p 80:${{ env.SERVER_PORT }} \ -e DATABASE_URL=$DATABASE_URL \ -e SERVER_PORT=$SERVER_PORT \ --restart unless-stopped \ ghcr.io/${{ github.repository_owner }}/${{ env.APP_NAME }}:latest

Note, if you have environment variables for your project, then make sure to add them as a repository secret and then in the code above, add them as a comma separated value within the envs option and with the '-e' flag of the docker run command. DATABASE_URL is provided as an example above, omit it if not applicable to your project.

This gives you idempotent deployments:

  • old container stopped
  • new container started
  • no manual SSH needed

5. Verifying the Deployment

A simple health check goes a long way.

- name: Health check run: | sleep 5 curl -f http://${{ secrets.VPS_HOST }}/health

Your Go service should expose:

http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) })

When Things Go Wrong

GitHub Actions provides logs to diagnose any failures but here are the most common issues I’ve seen.

1. It Works Locally, But Not in CI

Usually caused by:

  • missing env vars
  • different Go versions
  • relying on files not committed

Fix:

  • make sure your CI/CD variables and versions matches your local setup

2. SSH Deployments Hang

Common reasons:

  • host key verification prompts
  • incorrect permissions on host user or SSH key

Fix:

  • validate keys locally before adding to GitHub

Final Thoughts

When you automate deployments:

  • you ship faster
  • you break less

GitHub Actions + Go + Docker + a VPS is a powerful, minimal combo—and a great foundation before reaching for heavier infrastructure.

GitHub
LinkedIn