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:
- Trigger a deployment (push code or click a button)
- Build and test a Go service
- Build a Docker image
- Push it to a registry
- Deploy it to a VPS
- 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_HOSTVPS_USERVPS_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.