Go Microservice CI/CD with Docker
Containerize a Go microservice and automate builds with GitHub Actions.
Introduction
30 Second Summary
Every time you push code to a team project, someone has to check it compiles, passes tests, and packages correctly. Doing this manually slows everyone down and introduces human error.
In this project, you will build a Go microservice, package it into a tiny Docker container, and create a GitHub Actions pipeline that automatically tests and builds your code every time you push.
What You'll Build
A Go HTTP health-check microservice containerized with Docker and automated through a GitHub Actions CI/CD pipeline.
By the end of this project, you'll have:
- A Go HTTP microservice with unit tests that responds on /health and / endpoints.
- A multi-stage Dockerfile that shrinks your image from over 1GB to approximately 15MB.
- A GitHub Actions CI/CD pipeline that automatically runs tests and builds your Docker image on every push.
- Secret Mission: Publish your Docker image to GitHub Container Registry automatically on every push to main.
Are there any prerequisites?
You'll need a GitHub account, Go installed on your machine, Docker installed, and Git installed. Basic Go programming knowledge is helpful but not required.
Create the Go Project
Every microservice starts with a single question: is this thing alive? Your Go health-check service answers that question with two HTTP endpoints that return JSON.
In this step, you will create a Go project from scratch, write a simple HTTP server, and confirm it responds to requests on your machine.
In this step, get ready to:
- Create a Go module and project directory.
- Write an HTTP server with two JSON endpoints.
- Verify the server runs locally and responds to requests.
Create the project directory and initialize a Go module
A Go module is the unit of dependency management in Go. Running go mod init creates a go.mod file that names your module and tracks its Go version.
- Open GoLand's built-in terminal by pressing Option+F12 (macOS) or Alt+F12 (Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir go-health-service
cd go-health-service
- Initialize a Go module by running this command:
go mod init github.com/[[GITHUB_USERNAME="your-github-username"]]/go-health-service
Why include your GitHub username?
Go module paths follow the convention of the repository URL where the code will live. This makes your module globally unique and tells Go where to find the source if someone imports it.
- Confirm the files exist by running:
ls
You should see go.mod in the output.
Don't see go.mod?
- Make sure you ran go mod init from inside the go-health-service directory, not from the Desktop.
- Check that Go is installed by running go version. You should see go1.26 or higher.
Help me troubleshoot my Go module initialization.
Write the HTTP server in main.go
Your microservice needs two endpoints. The /health endpoint returns a JSON health status that monitoring tools can poll. The / endpoint returns basic service information.
- Open the project folder in GoLand by clicking File in the top menu bar, then Open.
- Navigate to Desktop and select the go-health-service folder, then click Open.
- Create a new file by right-clicking the project root in the Project tool window, then selecting New > Go File.
- Name the file main and select Simple Application as the type.
- Delete any boilerplate code GoLand generated so you start with an empty file.
Now you will build the file piece by piece. Start by adding the package declaration, imports, and the response struct.
- Copy the following code into main.go:
package main
import (
"encoding/json"
"log"
"net/http"
"time"
)
// HealthResponse defines the JSON shape returned by the /health endpoint
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
}
// healthHandler returns a JSON object with the service health status
func healthHandler(w http.ResponseWriter, r *http.Request) {
response := HealthResponse{
Status: "healthy",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
What does this code do?
- The HealthResponse struct defines the shape of the JSON your health endpoint returns. The backtick tags (json:"status") tell Go's JSON encoder what field names to use in the output.
- The healthHandler function creates a HealthResponse with the current UTC timestamp, sets the response header to JSON, and writes it to the client.
- Add the root handler and the main function below the healthHandler function:
// rootHandler returns basic service info as JSON
func rootHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"service": "go-health-service",
"version": "1.0.0",
})
}
func main() {
// Create a new HTTP request multiplexer
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler)
mux.HandleFunc("/", rootHandler)
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", mux); err != nil {
log.Fatal(err)
}
}
What does this code do?
- The rootHandler returns a simple JSON map with the service name and version. This gives callers a quick way to identify what service is running.
- The main function creates an http.ServeMux (a request router), registers both handlers to their paths, and starts listening on port 8080.
- Save main.go.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.go file for comparison:
package main
import (
"encoding/json"
"log"
"net/http"
"time"
)
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
response := HealthResponse{
Status: "healthy",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func rootHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"service": "go-health-service",
"version": "1.0.0",
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler)
mux.HandleFunc("/", rootHandler)
log.Println("Server starting on :8080")
if err := http.ListenAndServe(":8080", mux); err != nil {
log.Fatal(err)
}
}
And here is the go.mod file (auto-generated by go mod init):
module github.com/<your-username>/go-health-service
go 1.26
Run the server and test the health endpoint
Your code is written. Now you need to prove it actually works by starting the server and hitting the health endpoint with a request.
- Start the server by running this command in GoLand's terminal:
go run main.go
You should see Server starting on :8080 printed in the terminal. The terminal will stay occupied because the server is running.
- Open a second terminal tab by clicking the + icon in GoLand's Terminal tool window.
- Test the health endpoint by running:
curl http://localhost:8080/health
You should see a JSON response with "status":"healthy" and a "timestamp" field showing the current UTC time.
What is curl?
The curl command sends HTTP requests from your terminal. It is the simplest way to test an API endpoint without a browser or external tool.
- Stop the server by pressing Ctrl+C in the first terminal tab.
Seeing "connection refused"?
- Make sure the first terminal tab still shows Server starting on :8080. If you see a Go error instead, check main.go for typos.
- Confirm you are running curl in a separate terminal tab, not the same one where the server is running.
- If port 8080 is already in use, stop whatever process occupies it or restart your machine.
Help me fix my Go server connection issue.
Your Go microservice is running and responding to health checks. Next up, you will write automated tests so you can prove the endpoints work without manually running curl every time.
Write Unit Tests
Your Go microservice is running and responding to HTTP requests. That is great for local development, but a CI/CD pipeline needs a way to verify your code still works after every change.
Unit tests solve this problem. They exercise your handler functions in isolation, without needing to start a real server. Go's standard library includes a built-in testing framework and a net/http/httptest package purpose-built for testing HTTP handlers.
In this step, get ready to:
- Create a test file that verifies both of your HTTP endpoints.
- Run your tests locally and confirm they all pass.
Create the test file with TestHealthHandler
Go's testing convention is simple. For a file called main.go, the test file is called main_test.go. Go automatically discovers any file ending in _test.go and runs the test functions inside it.
- In GoLand, create a new file called main_test.go in your go-health-service folder.
- Add the package declaration, imports, and first test function by pasting this code:
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestHealthHandler verifies the /health endpoint returns correct JSON
func TestHealthHandler(t *testing.T) {
// Create a fake HTTP request to /health
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
// Call the handler directly (no server needed)
healthHandler(w, req)
// Check the status code is 200 OK
res := w.Result()
if res.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", res.StatusCode)
}
// Decode the JSON body and verify fields
var response HealthResponse
if err := json.NewDecoder(res.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.Status != "healthy" {
t.Errorf("expected status 'healthy', got '%s'", response.Status)
}
if response.Timestamp == "" {
t.Error("expected timestamp to be non-empty")
}
}
What does this code do?
The httptest package lets you test HTTP handlers without spinning up a real server. httptest.NewRequest creates a fake request, and httptest.NewRecorder captures whatever the handler writes back.
You call your handler function directly, passing in the fake request and recorder. Then you inspect the recorder to check the status code and JSON body match what you expect.
Add TestRootHandler
- Below TestHealthHandler, add the second test function that verifies the / endpoint:
// TestRootHandler verifies the / endpoint returns service info
func TestRootHandler(t *testing.T) {
// Create a fake HTTP request to /
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
// Call the handler directly
rootHandler(w, req)
// Check the status code is 200 OK
res := w.Result()
if res.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", res.StatusCode)
}
// Decode and verify the JSON fields
var response map[string]string
if err := json.NewDecoder(res.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["service"] != "go-health-service" {
t.Errorf("expected service 'go-health-service', got '%s'", response["service"])
}
if response["version"] != "1.0.0" {
t.Errorf("expected version '1.0.0', got '%s'", response["version"])
}
}
What does this code do?
This test follows the same pattern as TestHealthHandler, but it checks the / endpoint instead. It verifies that the response contains the correct service name and version string.
Notice the response type is map[string]string here instead of HealthResponse. That is because rootHandler returns a plain map, not a typed struct.
- Save main_test.go.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before running the tests.
ⓧ I'd like to double check the full code
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
healthHandler(w, req)
res := w.Result()
if res.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", res.StatusCode)
}
var response HealthResponse
if err := json.NewDecoder(res.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.Status != "healthy" {
t.Errorf("expected status 'healthy', got '%s'", response.Status)
}
if response.Timestamp == "" {
t.Error("expected timestamp to be non-empty")
}
}
func TestRootHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
rootHandler(w, req)
res := w.Result()
if res.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", res.StatusCode)
}
var response map[string]string
if err := json.NewDecoder(res.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response["service"] != "go-health-service" {
t.Errorf("expected service 'go-health-service', got '%s'", response["service"])
}
if response["version"] != "1.0.0" {
t.Errorf("expected version '1.0.0', got '%s'", response["version"])
}
}
Run the tests locally
Go ships with a built-in test runner. You do not need to install any extra test framework.
- Return to your terminal in the go-health-service directory. Run your tests by entering this command:
go test -v ./...
- You should see output showing each test function with --- PASS next to it, followed by a final ok line confirming the package passed.
What does this command do?
The -v flag means verbose. It prints the name and result of every test function, even when they pass. Without it, Go only shows output for failing tests.
The ./... pattern tells Go to run tests in the current directory and all subdirectories. For this project there is only one package, but it is a good habit to use this pattern.
Tests not passing?
Make sure main_test.go is saved in the same folder as main.go. Both files must be in the go-health-service directory for Go to find them.
Check that your package main declaration at the top of main_test.go matches the package name in main.go. Go requires test files to share the same package.
If you see a compile error, look for missing commas or braces in the test file. Help me fix my failing Go tests.
Your microservice has a safety net now. Every time you change your handler code, you can rerun these tests to catch regressions instantly. Next up, you will package everything into a tiny Docker container using a multi-stage build.
Containerize with a Multi-Stage Dockerfile
Your Go service runs locally and all tests pass. Now it's time to package it into a Docker container so it can run on any machine without needing Go installed.
A straightforward Docker build would produce an image over 1GB because it bundles the entire Go toolchain alongside your tiny binary. Multi-stage builds solve this by compiling in one stage, then copying only the finished binary into a minimal final image.
In this step, get ready to:
- Create a multi-stage Dockerfile that compiles and packages your service.
- Build and run the Docker image locally.
- Verify the image size reduction from over 1GB to approximately 15MB.
Create the multi-stage Dockerfile
A multi-stage Dockerfile uses multiple FROM statements. The first stage has everything needed to compile your code. The second stage is a stripped-down image that receives only the compiled binary.
Why multi-stage instead of a single stage?
A single-stage build using golang:1.26-alpine would ship the entire Go compiler, standard library source, and build tools alongside your 10MB binary. That's over 1GB of unnecessary baggage.
Multi-stage builds let you use the full toolchain for compilation, then discard it. Your production image only contains the binary and a minimal OS layer.
- In GoLand, create a new file called Dockerfile (no file extension) in the root of your go-health-service folder.
- Add the build stage by pasting this code:
# Build stage
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /go-health-service
What does the build stage do?
- FROM golang:1.26-alpine AS builder starts from a full Go image and names it builder so the next stage can reference it.
- COPY go.mod and RUN go mod download are separated from the rest of the code so Docker can cache dependencies between builds.
- CGO_ENABLED=0 produces a fully static binary with no C library dependencies. GOOS=linux ensures the binary targets Linux regardless of your host OS.
- Below the build stage, add the final stage:
# Final stage
FROM alpine:3.23
WORKDIR /
COPY --from=builder /go-health-service /go-health-service
EXPOSE 8080
USER nobody:nobody
ENTRYPOINT ["/go-health-service"]
What does the final stage do?
- FROM alpine:3.23 starts fresh from a minimal Alpine Linux image (about 5MB).
- COPY --from=builder pulls only the compiled binary from the build stage. Everything else (Go compiler, source code, build cache) is discarded.
- USER nobody:nobody runs the container as a non-root user for security.
- Save Dockerfile.
- Confirm the file exists by running this in your GoLand terminal:
ls Dockerfile
You should see Dockerfile printed in the output.
Don't see the file?
- Make sure the file is named exactly Dockerfile with a capital D and no extension (not Dockerfile.txt or dockerfile).
- Confirm you saved it in the root of go-health-service, not inside a subfolder.
Why isn't my Dockerfile showing up in GoLand?
✔️ Awesome, I've got everything!
Great. Make sure you've saved your Dockerfile before moving on to the build step.
ⓧ I'd like to double check the full code
# Build stage
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /go-health-service
# Final stage
FROM alpine:3.23
WORKDIR /
COPY --from=builder /go-health-service /go-health-service
EXPOSE 8080
USER nobody:nobody
ENTRYPOINT ["/go-health-service"]
Build and run your Docker image
- Build your Docker image by running this command:
docker build -t go-health-service .
What does this command do?
The -t go-health-service flag tags (names) your image so you can reference it later. The . tells Docker to look for the Dockerfile in your current directory.
Docker will pull the base images, compile your Go code, and produce the final image. You should see output ending with a line that says the image was successfully built and tagged.
Seeing a build error?
- If you see a "Cannot connect to the Docker daemon" message, make sure Docker Desktop is running on your machine.
- If you see a syntax error, double check your Dockerfile matches the full code in the tab above.
Help me fix my Docker build error.
- Run your container by executing:
docker run -p 8080:8080 go-health-service
What does this command do?
The -p 8080:8080 flag maps port 8080 on your machine to port 8080 inside the container. This lets you reach the service at localhost:8080 just like when you ran it directly with Go.
Your terminal is now attached to the running container. You should see the Server starting on :8080 log message.
- Open a second terminal tab in GoLand (click the + icon in the terminal panel).
- Test the health endpoint by running:
curl http://localhost:8080/health
You should see a JSON response containing "status": "healthy" and a timestamp. This proves your containerized service works.
- Switch back to the first terminal tab and press Ctrl+C to stop the container.
Port already in use?
- If you see a "port is already allocated" error, you may have your Go server still running from an earlier step. Stop it first, then try the docker run command again.
Why is port 8080 already in use on my machine?
Compare image sizes
The real power of multi-stage builds becomes visible when you check the final image size. Without multi-stage, a Go image based on the full golang base would be over 1GB.
- Check your image size by running:
docker images go-health-service
Your final image should be approximately 15MB. That's a reduction from over 1GB to a tiny, production-ready container.
Why does size matter?
Smaller images download faster, start faster, and have a smaller attack surface. In a CI/CD pipeline, every push triggers a build. A 15MB image uploads to a container registry in seconds. A 1GB image would take minutes.
Seeing a larger image size?
- If your image is significantly larger than 15MB, make sure you have FROM alpine:3.23 as your second stage (not FROM golang:1.26-alpine).
- Check that you have CGO_ENABLED=0 in your build command. Without it, the binary may link to C libraries that aren't in the final image.
Why is my Docker image larger than expected?
Your microservice is now containerized and production-ready. Next up, you'll push everything to GitHub so you can wire up an automated pipeline.
Push to GitHub
Your microservice is built, tested, and containerized. Right now it only lives on your local machine.
To automate your build-and-test cycle with a CI/CD pipeline, your code needs to live in a remote repository. In this step, you'll push your project to GitHub so it's ready for automation.
In this step, get ready to:
- Create a new public repository on GitHub.
- Initialize a local git repository, commit your files, and push to GitHub.
Create a new public repository on GitHub
Your CI/CD pipeline will live inside your GitHub repository. You need a public repository so GitHub Actions can run for free.
- Open your browser and go to github.com.
- Click the + icon in the top-right corner of the page.
- Select New repository.
- Enter go-health-service as the Repository name.
- Set the visibility to Public.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why leave the repository empty?
You already have files locally that you want to push. If GitHub creates a README or .gitignore for you, it creates an initial commit that conflicts with your local history. Starting with an empty repository avoids merge conflicts on your first push.
GitHub shows a quick setup page after creation. Keep this page open. You'll need the repository URL in a moment.
Initialize git, commit, and push your code
With the remote repository ready, you can now connect your local project to it. Git tracks every change you make, and pushing sends those changes to GitHub where your CI/CD pipeline can pick them up.
- Initialize a git repository, stage all files, commit, and push to your new GitHub repository by running these commands:
git init
git add .
git commit -m "feat: initial Go microservice with Docker"
git branch -M main
git remote add origin git@github.com:[[GITHUB_USERNAME="your GitHub username"]]/go-health-service.git
git push -u origin main
What do these commands do?
- git init creates a new git repository in your project folder.
- git add . stages all your files (main.go, main_test.go, Dockerfile, go.mod) for the commit.
- git commit -m "..." saves a snapshot of your staged files with a descriptive message.
- git branch -M main renames the default branch to main.
- git remote add origin connects your local repository to the GitHub repository you just created.
- git push -u origin main uploads your commit to GitHub's main branch.
- Refresh your GitHub repository page in the browser. You should see all four files listed: main.go, main_test.go, Dockerfile, and go.mod.
Push failed or permission denied?
If you see a "Permission denied (publickey)" error, your SSH key is not configured with GitHub. You can switch to HTTPS instead by replacing the remote URL:
git remote set-url origin https://github.com/[[GITHUB_USERNAME="your GitHub username"]]/go-health-service.git
git push -u origin main
If you see "remote origin already exists", that means git already has a remote configured. Remove it and re-add:
git remote remove origin
git remote add origin git@github.com:[[GITHUB_USERNAME="your GitHub username"]]/go-health-service.git
git push -u origin main
help me push my code to GitHub
Your code is now live on GitHub. Next up, you'll create a GitHub Actions workflow that automatically tests and builds your container every time you push new code.
Set Up the GitHub Actions CI/CD Pipeline
Your code is on GitHub, your Docker image builds locally, and your tests pass. The missing piece is automation. Right now, you have to remember to run tests and build the image manually every time you change something.
GitHub Actions lets you define a CI/CD pipeline as a YAML file in your repository. Every time you push code, GitHub spins up a fresh Linux machine, runs your tests, and builds your Docker image automatically.
In this step, get ready to:
- Create a GitHub Actions workflow file that triggers on every push.
- Push the workflow to GitHub and verify the pipeline runs successfully.
Create the workflow directory and file
GitHub Actions looks for workflow files in a specific directory path: .github/workflows/. Any YAML file in that folder becomes a pipeline definition.
- Create the workflow directory by running this command in your terminal (make sure you're inside the go-health-service folder):
mkdir -p .github/workflows
What does this command do?
The -p flag tells mkdir to create parent directories as needed. It creates both .github/ and workflows/ inside it in one command.
- Confirm the directory was created by running:
ls -la .github/workflows
You should see an empty directory listing (just . and .. entries).
- Create a new file called ci.yml inside .github/workflows/ and paste the following workflow definition:
name: CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26.x"
- name: Run tests
run: go test -v ./...
- name: Build Docker image
run: docker build -t go-health-service .
What does this workflow do?
This file defines a complete CI pipeline with four key sections:
- on: triggers the pipeline on every push or pull request targeting the main branch.
- actions/checkout@v6 clones your repository code onto the runner machine.
- actions/setup-go@v5 installs Go 1.26.x on the runner so your code can compile.
- The two run: steps execute your tests and then build the Docker image, just like you did locally.
Why GitHub Actions over Jenkins?
GitHub Actions is free for public repositories and requires zero infrastructure to manage. Jenkins needs its own server, which adds complexity for a portfolio project.
GitHub Actions is also the most commonly requested CI/CD tool in DevOps job listings alongside Jenkins. Having a working pipeline in your public repo is visible proof of your skills.
- Save .github/workflows/ci.yml.
File not saving to the right place?
Make sure the file is at .github/workflows/ci.yml relative to your project root. The path must be exact for GitHub to detect it. If you accidentally created it at github/workflows/ci.yml (missing the leading dot), GitHub will not find it.
Help me fix my workflow file path.
Commit and push the workflow file
The moment you push this file to GitHub, the pipeline triggers automatically. GitHub detects new workflow files and starts running them immediately.
- Stage, commit, and push your workflow file by running these commands:
git add .github/workflows/ci.yml
git commit -m "ci: add GitHub Actions pipeline"
git push
What does this command do?
git add stages the new workflow file. The commit message uses the ci: prefix, which is a common convention for commits that modify CI/CD configuration. Then git push sends it to GitHub, which triggers the pipeline.
You should see the push succeed with output confirming the commit was sent to the main branch.
Push rejected or authentication failed?
If you see a permission error, your SSH key or personal access token may not be configured for this repository. Make sure you set up authentication in the previous step when you first pushed to GitHub.
Help me fix my GitHub push authentication.
Verify the pipeline runs on GitHub
Your pipeline is now running on GitHub's servers. You can watch it in real time from the Actions tab.
- Open your browser and go to your repository at github.com/<your-username>/go-health-service.
- Click the Actions tab at the top of the repository page.
- You should see a workflow run named ci: add GitHub Actions pipeline (matching your commit message). Click on it.
- Click on the build job to expand the step details.
You should see green checkmarks next to each step: checkout, Set up Go, Run tests, and Build Docker image. This confirms your entire pipeline passed.
What should I see?
Each step expands to show its logs. The Run tests step shows the same go test -v output you saw locally (PASS for both TestHealthHandler and TestRootHandler). The Build Docker image step shows the multi-stage build completing successfully.
Seeing a red X instead of green checkmarks?
Click on the failed step to read the error log. Common causes:
- YAML indentation error. YAML is sensitive to spaces. Make sure you used spaces (not tabs) and that the indentation matches the file above exactly.
- Test failure. If the Run tests step fails, check that your main_test.go passes locally with go test -v ./... before investigating further.
- Docker build failure. If the Build Docker image step fails, confirm your Dockerfile was committed and pushed in the previous step.
Help me debug my GitHub Actions pipeline failure.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and that your Actions tab shows green checkmarks.
ⓧ I'd like to double check the full code
Here is the complete .github/workflows/ci.yml file:
name: CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26.x"
- name: Run tests
run: go test -v ./...
- name: Build Docker image
run: docker build -t go-health-service .
Secret mission
Publish to GitHub Container Registry
Your pipeline builds a Docker image, but where does it go? Right now it vanishes into thin air after the workflow finishes. Extend your CI/CD pipeline to automatically publish your image to GitHub Container Registry so it is ready to deploy anywhere.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses GitHub Actions on a public repository, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local project folder go-health-service on your Desktop.
- Local Docker image go-health-service.
- GitHub repository go-health-service.
- GitHub Container Registry package ghcr.io/<your-username>/go-health-service.
Keep everything running
No action needed. Choose this if you're still actively building or want to reference this project later.
- Your GitHub Actions pipeline continues to run on every push at no cost (public repository).
- Your local Docker image stays cached and uses a small amount of disk space.
- Your GHCR package remains published and publicly pullable.
Pause - I'll come back to this later
Free up local disk space while keeping your GitHub resources intact for later.
- Remove the local Docker image by running:
docker rmi go-health-service
Your GitHub repository and GHCR package remain online and cost nothing. When you come back, rebuild the image from the project folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- Delete the local Docker image by running:
docker rmi go-health-service
- Delete the local project folder.
macOS
rm -rf ~/Desktop/go-health-service
Windows
Remove-Item -Recurse -Force "$env:USERPROFILE\Desktop\go-health-service"
- Delete the GitHub Container Registry package:
- Open your go-health-service repository on GitHub.
- In the right sidebar, click Packages.
- Click the go-health-service package.
- On the package landing page, click Package settings on the right-hand side.
- At the bottom under "Danger Zone", click Delete this package.
- Type the package name to confirm, then click I understand, delete this package.
- Delete the GitHub repository:
- Open your go-health-service repository on GitHub.
- Click Settings under the repository name.
- Scroll down to the "Danger Zone" section and click Delete this repository.
- Click I want to delete this repository.
- Click I have read and understand these effects.
- Type the repository name to confirm, then click Delete this repository.
Nice Work!
Nice Work!
You just built your first DevOps pipeline from scratch. You now have a live CI/CD workflow visible in your GitHub repository's Actions tab, proving you can containerize and automate the delivery of a Go microservice.
You learned how to:
- Build a Go HTTP microservice with unit tests using table-driven test patterns and Go's standard library.
- Containerize your application with a multi-stage Docker build that shrinks the image from over 1GB down to approximately 15MB.
- Create a GitHub Actions CI/CD pipeline that automatically runs tests and builds your Docker image on every push to main.
- Published your Docker image to GitHub Container Registry automatically on every push.
Ready to quiz yourself?