Ship a Production Container

Build, test, and publish a production Docker image with GitHub Actions.

Introduction

30 Second Summary

Software can work perfectly on your computer yet break when someone else tries to run it. A dependable delivery path gives everyone the same tested version.

In this project, you will package ShipTrack, a compact order-status API, as a production-focused Docker container. A GitHub Actions workflow tests every change before publishing successful main-branch images to GitHub Packages Container registry.

What You'll Build

Your finished demo shows a JSON order response from the exact published image with Docker reporting the container as healthy.

By the end of this project, you'll have:

  • A live API response for ORD-1001 that shows its delivery status in your browser.
  • Compared the naive image with your production container. You'll see a smaller image that reports healthy through Docker.
  • Watched your delivery workflow build a proposed change. You'll pull the successful main-branch image from ghcr.io without rebuilding it locally.
  • Secret Mission: Publish a second version. Roll ShipTrack back to the first image by its immutable digest after latest moves.

Are there any prerequisites?

You need Docker Desktop in Linux container mode, Git, Visual Studio Code, and a GitHub account on Windows. No local Go installation is required because Go runs entirely inside Docker.

Before We Start

This is your moment to define the delivery goal before the hands-on work begins. You are committing to build a tested production container for the ShipTrack order-tracking API and publish successful builds through an automated GitHub delivery workflow.

Verify Your Delivery Workstation

ShipTrack's delivery path begins on your Windows workstation. Docker Desktop must provide a running Linux engine before you can build its production container.

Git carries the project into its remote repository. Visual Studio Code keeps the workspace plus its terminal in one place.

You also need access to your GitHub account for publishing later. These checks prevent workstation issues from surfacing as delivery failures.

In this step, get ready to:
  • Start Docker Desktop in Linux container mode.
  • Prepare the empty shiptrack-api workspace in Visual Studio Code.
  • Verify the delivery toolchain from PowerShell.
Start Docker Desktop with Linux containers

Docker Desktop can run either a Linux container engine or a Windows container engine. ShipTrack uses Linux images throughout its delivery path.

  • Press the Windows key to open Windows search.
  • Type Docker Desktop and press Enter.
  • Wait for Docker Desktop to finish starting.
  • Select the Docker icon in the Windows taskbar notification area to open the Docker menu.

The switch shown in this menu names the other engine mode. Choose the tab that matches the label you see.

✔️ I see Switch to Windows containers

Docker Desktop is already using Linux containers. The menu offers Windows containers because that is the available switch.

ⓧ I see Switch to Linux containers

  • Select Switch to Linux containers.
  • Wait for Docker Desktop to complete the engine switch.
  • Open the Docker menu again.

You should now see Switch to Windows containers. That label confirms Docker Desktop is using Linux containers.

Good start. Docker Desktop is now using the container mode that ShipTrack needs.

Create the empty workspace

An empty workspace gives ShipTrack a clear home before any source files exist. Its integrated PowerShell terminal starts inside that same folder.

  • Press Windows+E to open File Explorer.

You should see File Explorer open with your common folders in the left navigation pane.

  • Select Desktop in the left navigation pane.
  • Press Ctrl+Shift+N to create a new folder.
  • Type shiptrack-api and press Enter.

You should see the empty shiptrack-api folder on your Desktop.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code and press Enter.

Visual Studio Code should open in a new window.

  • Click File in the top menu bar.
  • Click Open Folder....

You should see the Windows folder selection dialog.

  • Select Desktop in the folder selection dialog.

You should see shiptrack-api listed on the Desktop.

  • Select the shiptrack-api folder.
  • Click Select Folder.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

The Explorer sidebar should show shiptrack-api with no project files underneath it.

  • Press Ctrl+` to open the integrated terminal.

The terminal should open at the bottom of Visual Studio Code. Its prompt should end inside the shiptrack-api folder.

  • Select the Launch Profile dropdown if the terminal is not using PowerShell.
  • Select PowerShell.
Verify the delivery toolchain

GitHub hosts the repository that triggers your automated delivery workflow. Confirming account access now prevents an authentication detour when you publish.

Your credentials stay on GitHub's sign-in page. You never paste them into this project.

  • Visit the GitHub homepage in your browser.
  • Sign in to your existing account if your personal dashboard does not appear.
  • Confirm that your personal dashboard is displayed.
  • Switch back to the integrated PowerShell terminal in Visual Studio Code.

Before you run these checks, predict which command depends on a background service as well as an installed client.

  • Verify all three command-line tools by running these checks:
docker version
git --version
code --version

What do these checks prove?

  • The docker check confirms the command-line client can communicate with the running Docker engine.
  • The git check confirms Git is available for creating commits plus pushing the project.
  • The code check confirms the Visual Studio Code command is available from PowerShell.

Docker should print both Client and Server sections.

Git should print its version information. Visual Studio Code should print its version information.

✔️ All three checks return the expected information

Your delivery workstation is ready. Docker can reach its engine while Git plus Visual Studio Code are available from the project terminal.

ⓧ Docker has no Server section

The Docker client is installed, but it cannot reach a running engine. Return to Docker Desktop to restore that connection.

  • Switch back to Docker Desktop from earlier.
  • Wait for Docker Desktop to finish starting.
  • Open the Docker menu from the Windows taskbar notification area.
  • Select Switch to Linux containers if that option appears.
  • Return to the integrated PowerShell terminal in Visual Studio Code.
  • Run the three checks above again.

Still missing the Server section?

Docker Desktop may still be starting. Wait until startup completes before repeating the check.

Ask for help if the client still cannot reach the engine: help me restore the Docker Server connection on Windows.

ⓧ A command is not found

A missing command means its tool is absent or unavailable through the terminal's search path. Use the official guide for the command that failed.

Command still unavailable?

A new terminal session reloads the updated command search path. Make sure the installer has finished before opening that session.

Ask for help with the specific command that remains unavailable: help me fix the missing command in PowerShell.

Your workstation now has a reachable Linux container engine plus the tools needed to author and publish ShipTrack. Next, you will turn the empty workspace into the first working container.

Ship the First Working Container

Your workstation can reach Docker's Linux engine. ShipTrack now needs a working API that the engine can package.

The fastest path is a single-stage container that proves order tracking works in a browser. Once it runs, you can judge the image from evidence.

In this step, get ready to:
  • Create the ShipTrack source files.
  • Package ShipTrack in a single-stage image.
  • Run the container to inspect its behavior.
Create the ShipTrack API

A Go module gives the project a stable import path. The API package holds the order data plus the request handlers.

  • Create go.mod directly inside the shiptrack-api folder using VS Code's Explorer sidebar.
  • Paste this module definition into go.mod:
module example/shiptrack

go 1.27.3

What does this module file do?

The module path example/shiptrack gives the internal package a consistent import path. The go 1.27.3 directive records the Go language version used by this project.

  • Save go.mod.

The go.mod tab loses its unsaved marker. You can also see the file directly inside shiptrack-api in the Explorer sidebar.

Cannot see go.mod?

Check that you created the file directly inside shiptrack-api. A file nested inside another folder cannot define the module from the expected location.

Help me check the location of my Go module file.

  • Create an internal folder inside shiptrack-api using the Explorer sidebar.

You should see internal nested directly below shiptrack-api.

  • Create an api folder inside internal using the Explorer sidebar.

The Explorer sidebar should now show the path internal/api.

  • Create api.go inside internal/api using the Explorer sidebar.
  • Paste the package imports plus the response data into api.go:
package api

import (
	"encoding/json"
	"log"
	"net/http"
)

type Order struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	ETA    string `json:"eta"`
}

type healthResponse struct {
	Status string `json:"status"`
}

type errorResponse struct {
	Error string `json:"error"`
}

var orders = map[string]Order{
	"ORD-1001": Order{ID: "ORD-1001", Status: "in_transit", ETA: "2026-11-05"},
	"ORD-1002": Order{ID: "ORD-1002", Status: "delivered", ETA: "2026-11-02"},
}

How is the API data shaped?

  • Order defines the fields returned by the order endpoint.
  • healthResponse keeps the health endpoint response focused on service status.
  • errorResponse provides a predictable error shape for missing orders.
  • orders stores two records that make the first container easy to demonstrate.
  • Save api.go.
  • Expand VS Code's Outline view to confirm Order, healthResponse, errorResponse, plus orders are listed.
  • Append the JSON response helper to api.go by pasting this code below the orders map:
func writeJSON(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(payload); err != nil {
		log.Printf("encode response: %v", err)
	}
}

What does writeJSON do?

  • writeJSON applies the JSON content type to every response.
  • json.NewEncoder(w).Encode(payload) writes the supplied value into the HTTP response body.
  • log.Printf() records an encoding problem if a response cannot be produced.
  • Save api.go.
  • Confirm writeJSON now appears in the Outline view.
  • Append the endpoint handlers to api.go by pasting this code below writeJSON:
func healthHandler(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, healthResponse{Status: "ok"})
}

func orderHandler(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	order, found := orders[id]
	if !found {
		writeJSON(w, http.StatusNotFound, errorResponse{Error: "order not found"})
		return
	}

	writeJSON(w, http.StatusOK, order)
}

How do the handlers respond?

  • healthHandler returns an OK response that proves the service can answer requests.
  • orderHandler reads the order ID from the URL path.
  • http.StatusNotFound gives unknown order IDs a clear failure response.
  • Save api.go.
  • Confirm healthHandler plus orderHandler now appear in the Outline view.
  • Append the router constructor to api.go by pasting this code below orderHandler:
func Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", healthHandler)
	mux.HandleFunc("GET /orders/{id}", orderHandler)
	return mux
}

What does Handler connect?

Handler creates the request router used by the executable. It connects GET /health plus GET /orders/{id} to their handlers.

  • Save api.go.
  • Confirm Handler now appears in the Outline view.

Missing symbols in Outline?

Check that each function sits below the previous closing brace. A function pasted inside another function cannot appear as a top-level symbol.

Help me find a misplaced brace in api.go.

  • Create a cmd folder inside shiptrack-api using the Explorer sidebar.

You should see cmd beside internal in the Explorer sidebar.

  • Create an api folder inside cmd using the Explorer sidebar.

The executable path now appears as cmd/api.

  • Create main.go inside cmd/api using the Explorer sidebar.
  • Paste the executable code into main.go:
package main

import (
	"log"
	"net/http"

	"example/shiptrack/internal/api"
)

func main() {
	address := ":8080"
	log.Printf("ShipTrack listening on %s", address)
	if err := http.ListenAndServe(address, api.Handler()); err != nil {
		log.Fatal(err)
	}
}

How does the executable start ShipTrack?

  • address stores the port used inside the container.
  • http.ListenAndServe() starts the HTTP server.
  • api.Handler() supplies the router you created in internal/api/api.go.
  • Save main.go.
  • Confirm main appears in the Outline view.
Add the naive Dockerfile

A single-stage image uses one base image for compilation plus runtime. This first Dockerfile keeps the delivery path short enough to get ShipTrack running quickly.

  • Create Dockerfile directly inside shiptrack-api using the Explorer sidebar.
  • Paste this single-stage image definition into Dockerfile:
FROM golang:1.27.3-alpine3.24
WORKDIR /src
COPY go.mod ./
COPY internal ./internal
COPY cmd/api ./cmd/api
RUN go build -o /shiptrack ./cmd/api
EXPOSE 8080
ENTRYPOINT ["/shiptrack"]

How does this image work?

  • FROM golang:1.27.3-alpine3.24 supplies the Go toolchain inside the image.
  • COPY places the module plus source files in /src.
  • RUN go build -o /shiptrack ./cmd/api compiles the API executable into the image.
  • ENTRYPOINT ["/shiptrack"] starts ShipTrack when the container runs.
  • Save Dockerfile.

The Explorer sidebar should now show Dockerfile beside go.mod.

Dockerfile showing as a text file?

Confirm the filename is exactly Dockerfile. Remove any hidden .txt extension.

Help me verify my Dockerfile name in VS Code.

✔️ Awesome, I've got everything!

Great. Save every open file before building the image.

ⓧ I'd like to double check the full code

Compare each file with the complete versions below.

go.mod

module example/shiptrack

go 1.27.3

Module checkpoint

This file defines the example/shiptrack module with Go version 1.27.3.

internal/api/api.go

package api

import (
	"encoding/json"
	"log"
	"net/http"
)

type Order struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	ETA    string `json:"eta"`
}

type healthResponse struct {
	Status string `json:"status"`
}

type errorResponse struct {
	Error string `json:"error"`
}

var orders = map[string]Order{
	"ORD-1001": Order{ID: "ORD-1001", Status: "in_transit", ETA: "2026-11-05"},
	"ORD-1002": Order{ID: "ORD-1002", Status: "delivered", ETA: "2026-11-02"},
}

func writeJSON(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(payload); err != nil {
		log.Printf("encode response: %v", err)
	}
}

func healthHandler(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, healthResponse{Status: "ok"})
}

func orderHandler(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	order, found := orders[id]
	if !found {
		writeJSON(w, http.StatusNotFound, errorResponse{Error: "order not found"})
		return
	}

	writeJSON(w, http.StatusOK, order)
}

func Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", healthHandler)
	mux.HandleFunc("GET /orders/{id}", orderHandler)
	return mux
}

API checkpoint

This package defines the response types plus the in-memory orders. It also connects both endpoints to their handlers.

cmd/api/main.go

package main

import (
	"log"
	"net/http"

	"example/shiptrack/internal/api"
)

func main() {
	address := ":8080"
	log.Printf("ShipTrack listening on %s", address)
	if err := http.ListenAndServe(address, api.Handler()); err != nil {
		log.Fatal(err)
	}
}

Executable checkpoint

This executable starts ShipTrack on port 8080 with the API router.

Dockerfile

FROM golang:1.27.3-alpine3.24
WORKDIR /src
COPY go.mod ./
COPY internal ./internal
COPY cmd/api ./cmd/api
RUN go build -o /shiptrack ./cmd/api
EXPOSE 8080
ENTRYPOINT ["/shiptrack"]

Image checkpoint

This single-stage definition compiles /shiptrack inside the Go image. The resulting image starts that executable on launch.

Build and inspect the first container

The source files now describe the service plus its runtime image. Building the image is the first test that every path and import fits together.

  • Build the naive image from the integrated PowerShell terminal by running:
docker build -t shiptrack:naive .

What does this build command do?

docker build reads the Dockerfile from the current folder. The tag shiptrack:naive gives the resulting image a local name.

The terminal should show each Dockerfile instruction completing. The command then returns control to PowerShell.

Image build failed?

Confirm the PowerShell prompt is inside the open shiptrack-api folder. Check the build output for the first source path or Go line that failed.

Help me diagnose my ShipTrack image build.

  • Start the ShipTrack container with a loopback-only port binding by running:
docker run --name shiptrack -d -p 127.0.0.1:8080:8080 shiptrack:naive

What does this run command do?

  • --name shiptrack gives the container a stable local name.
  • -d runs ShipTrack in the background.
  • -p 127.0.0.1:8080:8080 maps the container port to the same port on your Windows workstation.

You should see a container ID in PowerShell. This confirms Docker started ShipTrack in the background.

Container did not start?

A previous container named shiptrack can block this command. A different process using port 8080 can also prevent the binding.

Help me find why the ShipTrack container did not start.

  • Press the Windows key to open Search.
  • Type the name of your browser into Search.
  • Press Enter to open the browser.
  • Navigate to http://localhost:8080/health using the address bar.

You should see {"status":"ok"} in the browser. The health endpoint proves the running process can answer a request.

  • Navigate to http://localhost:8080/orders/ORD-1001 using the address bar.

You should see the JSON order response with ID ORD-1001, status in_transit, plus ETA 2026-11-05.

Before you inspect the container, do you think Docker can report its health from successful browser responses alone?

  • Inspect the ShipTrack image plus the running container by running:
docker image ls shiptrack
docker ps

What do these checks reveal?

docker image ls shiptrack shows the naive tag plus its size. The single-stage image contains the Go toolchain that compiled ShipTrack.

docker ps shows the container running without a health result. The browser endpoint works, but Docker has no health-check instruction to evaluate.

  • Record the SIZE value for shiptrack:naive here: your naive image size.

You have crossed the first delivery hurdle: ShipTrack now serves real order data from a Docker container.

Next, you will turn this working image into a smaller production-focused artifact with automated checks.

Build the Production Image

Your first ShipTrack container proves the API can run inside Docker. The image still carries the entire Go toolchain.

Docker also has no health signal for the running API. That leaves the container platform unable to confirm whether ShipTrack can serve requests.

A build-time smoke test will gate the image on real API behavior. A multi-stage image will create a smaller runtime.

The runtime will also include an executable health check. Docker can then report whether ShipTrack is healthy.

In this step, get ready to:
  • Add an API smoke test and an executable health check.
  • Replace the single-stage image with a test-gated multi-stage image.
  • Compare both images and confirm the production container becomes healthy.
Add the smoke test and health check

The smoke test calls api.Handler() during the image build. A failed order response stops the image before it reaches the runtime stage.

  • Create `cmd/smoketest/main.go` inside the `shiptrack-api` workspace by using the Explorer sidebar's New File control.
  • Add the smoke test imports by pasting this first code chunk into cmd/smoketest/main.go:
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"net/http/httptest"

	"example/shiptrack/internal/api"
)

What do these imports provide?

  • The JSON package decodes the response body into the exported Order type.
  • The HTTP test package creates an in-memory request and response recorder.
  • The API package gives the smoke test access to api.Handler().
  • Add the smoke test function below the import group by pasting this code:
func main() {
	request := httptest.NewRequest(http.MethodGet, "/orders/ORD-1001", nil)
	recorder := httptest.NewRecorder()
	api.Handler().ServeHTTP(recorder, request)

	response := recorder.Result()
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		log.Fatalf("smoke test returned %s", response.Status)
	}

	var order api.Order
	if err := json.NewDecoder(response.Body).Decode(&order); err != nil {
		log.Fatal(err)
	}
	if order.ID != "ORD-1001" || order.Status != "in_transit" {
		log.Fatalf("unexpected order response: %+v", order)
	}

	log.Print("API smoke test passed")
}

What does the smoke test prove?

  • The test sends an in-memory request to /orders/ORD-1001 through the real ShipTrack router.
  • The status check requires a successful HTTP response.
  • The decoded order check requires the expected ID and status.
  • The final log line gives the Docker build a visible success signal.
  • Save cmd/smoketest/main.go.

The running container needs a smaller probe than the full smoke test. The health-check executable requests the existing /health endpoint and exits unsuccessfully when the API cannot respond.

  • Create cmd/healthcheck/main.go inside the shiptrack-api workspace by using the Explorer sidebar's New File control.
  • Add the health-check program by pasting this code into cmd/healthcheck/main.go:
package main

import (
	"log"
	"net/http"
)

func main() {
	response, err := http.Get("http://localhost:8080/health")
	if err != nil {
		log.Fatal(err)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		log.Fatalf("health check returned %s", response.Status)
	}
}

How does the health check work?

  • The program requests ShipTrack from inside the running container.
  • A connection failure causes the executable to exit unsuccessfully.
  • A response outside the successful status range used here also causes an unsuccessful exit.
  • Docker uses that exit result to calculate the container's health status.
  • Save cmd/healthcheck/main.go.
  • Confirm the Explorer sidebar now shows main.go inside both cmd/smoketest and cmd/healthcheck.

Missing one of the new programs?

Check that both folders sit inside the existing cmd folder. Each folder must contain a file named main.go.

Ask for help if your Explorer tree differs from the expected paths: help me correct the ShipTrack smoke-test and health-check file paths

Create the multi-stage runtime image

A multi-stage Dockerfile separates compilation from execution. The final stage receives the two runtime binaries without receiving the Go toolchain.

  • Select Dockerfile in the Explorer sidebar.
  • Replace the entire file with this multi-stage version:
# syntax=docker/dockerfile:1

FROM golang:1.27.3-alpine3.24 AS build
WORKDIR /src
COPY go.mod ./
COPY internal ./internal
COPY cmd ./cmd
RUN go build -o /out/smoketest ./cmd/smoketest
RUN /out/smoketest
RUN go build -o /out/shiptrack ./cmd/api
RUN go build -o /out/healthcheck ./cmd/healthcheck

FROM alpine:latest
COPY --from=build /out/shiptrack /shiptrack
COPY --from=build /out/healthcheck /healthcheck
USER 10001:10001
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=3s --start-period=2s --retries=3 CMD ["/healthcheck"]
ENTRYPOINT ["/shiptrack"]

Why a multi-stage image?

  • The build stage compiles the smoke test before running it against the API router.
  • A failed smoke test stops the build before the runtime image is produced.
  • The final stage copies only /shiptrack and /healthcheck from the build stage.
  • The numeric user and group keep the API process away from the container's root identity.
  • The health-check instruction runs the dedicated probe on a repeating schedule.
  • Save Dockerfile.

Docker sends a build context containing the workspace files to the builder. A .dockerignore file keeps repository metadata and documentation outside that context.

  • Create .dockerignore inside the top level of shiptrack-api by using the Explorer sidebar's New File control.
  • Add the ignored paths by pasting these lines:
.git
.github
README.md

What stays out of the build context?

The ignore file excludes Git history, the future workflow directory, and the project readme. None of those files are required to compile ShipTrack.

  • Save .dockerignore.

Seeing Dockerfile warnings in VS Code?

Confirm the first line starts with the syntax directive. Check that both FROM instructions begin at the start of their lines.

Use this prompt for help with a mismatch: help me compare my multi-stage Dockerfile with the ShipTrack target

✔️ Awesome, I've got everything!

Your two Go programs are saved. Your production Dockerfile and build-context exclusions are also ready.

ⓧ I'd like to double check the full code

Compare every project file below with your workspace. The files include the unchanged API code so you can verify the full project state.

go.mod:

module example/shiptrack

go 1.27.3

What to compare

Confirm the module path and Go version match exactly.

internal/api/api.go:

package api

import (
	"encoding/json"
	"log"
	"net/http"
)

type Order struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	ETA    string `json:"eta"`
}

type healthResponse struct {
	Status string `json:"status"`
}

type errorResponse struct {
	Error string `json:"error"`
}

var orders = map[string]Order{
	"ORD-1001": Order{ID: "ORD-1001", Status: "in_transit", ETA: "2026-11-05"},
	"ORD-1002": Order{ID: "ORD-1002", Status: "delivered", ETA: "2026-11-02"},
}

func writeJSON(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(payload); err != nil {
		log.Printf("encode response: %v", err)
	}
}

func healthHandler(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, healthResponse{Status: "ok"})
}

func orderHandler(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	order, found := orders[id]
	if !found {
		writeJSON(w, http.StatusNotFound, errorResponse{Error: "order not found"})
		return
	}

	writeJSON(w, http.StatusOK, order)
}

func Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", healthHandler)
	mux.HandleFunc("GET /orders/{id}", orderHandler)
	return mux
}

What to compare

Confirm the API still exports Order and Handler(). The smoke test depends on both identifiers.

cmd/api/main.go:

package main

import (
	"log"
	"net/http"

	"example/shiptrack/internal/api"
)

func main() {
	address := ":8080"
	log.Printf("ShipTrack listening on %s", address)
	if err := http.ListenAndServe(address, api.Handler()); err != nil {
		log.Fatal(err)
	}
}

What to compare

Confirm the API still listens on port 8080 through api.Handler().

cmd/smoketest/main.go:

package main

import (
	"encoding/json"
	"log"
	"net/http"
	"net/http/httptest"

	"example/shiptrack/internal/api"
)

func main() {
	request := httptest.NewRequest(http.MethodGet, "/orders/ORD-1001", nil)
	recorder := httptest.NewRecorder()
	api.Handler().ServeHTTP(recorder, request)

	response := recorder.Result()
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		log.Fatalf("smoke test returned %s", response.Status)
	}

	var order api.Order
	if err := json.NewDecoder(response.Body).Decode(&order); err != nil {
		log.Fatal(err)
	}
	if order.ID != "ORD-1001" || order.Status != "in_transit" {
		log.Fatalf("unexpected order response: %+v", order)
	}

	log.Print("API smoke test passed")
}

What to compare

Confirm the smoke test checks ORD-1001 for the expected successful response and status.

cmd/healthcheck/main.go:

package main

import (
	"log"
	"net/http"
)

func main() {
	response, err := http.Get("http://localhost:8080/health")
	if err != nil {
		log.Fatal(err)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		log.Fatalf("health check returned %s", response.Status)
	}
}

What to compare

Confirm the health-check executable requests the local API health endpoint.

Dockerfile:

# syntax=docker/dockerfile:1

FROM golang:1.27.3-alpine3.24 AS build
WORKDIR /src
COPY go.mod ./
COPY internal ./internal
COPY cmd ./cmd
RUN go build -o /out/smoketest ./cmd/smoketest
RUN /out/smoketest
RUN go build -o /out/shiptrack ./cmd/api
RUN go build -o /out/healthcheck ./cmd/healthcheck

FROM alpine:latest
COPY --from=build /out/shiptrack /shiptrack
COPY --from=build /out/healthcheck /healthcheck
USER 10001:10001
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=3s --start-period=2s --retries=3 CMD ["/healthcheck"]
ENTRYPOINT ["/shiptrack"]

What to compare

Confirm the file contains both stages. Check the smoke-test gate before the runtime stage.

.dockerignore:

.git
.github
README.md

What to compare

Confirm each ignored path appears on its own line.

Rebuild and verify the production image

The existing container still owns the shiptrack name and port binding. Removing it makes room for the production container while leaving shiptrack:naive available for comparison.

  • Remove the running naive container by running this command in the integrated PowerShell terminal:
docker rm -f shiptrack

What does this command change?

The command stops and removes the running container named shiptrack. It does not remove the shiptrack:naive image.

The uncached build takes longer than the earlier build because Docker rebuilds every layer. A quiet stretch during compilation is expected.

Before you build, do you expect the runtime stage to be created if the smoke test finds the wrong order response?

  • Build the production image without cache by running this command:
docker build --no-cache -t shiptrack:prod .

What should the build prove?

  • The no-cache option forces Docker to execute the smoke-test layer during this build.
  • The build log prints API smoke test passed before the API and health-check binaries are copied into the runtime stage.
  • A failed test stops the build before shiptrack:prod is created.

That is the delivery gate working. ShipTrack now has to satisfy its order contract before Docker can produce the production image.

Did the production build stop?

Check that both new main.go files are saved. Confirm the Dockerfile copies the complete cmd folder into the build stage.

Use the smoke-test log to locate the failed check. You can also ask for help reading the failed ShipTrack build layer

  • Start the production container by running this command:
docker run --name shiptrack -d -p 127.0.0.1:8080:8080 shiptrack:prod

What does the run command do?

Docker starts shiptrack:prod in the background with the container name shiptrack.

The port binding exposes ShipTrack only through the workstation's loopback address.

  • Navigate to http://localhost:8080/health in your browser.

You should see {"status":"ok"}. This confirms the health endpoint still responds from the production image.

  • Navigate to http://localhost:8080/orders/ORD-1001 in your browser.

You should see the ORD-1001 JSON response with status in_transit. The smaller runtime serves the same API behavior as the naive image.

Before you check the image and container lists, what differences do you expect to see between the two image sizes and the container status?

  • Compare the images and inspect the running container by running these commands:
docker image ls shiptrack
docker ps

What should the comparison show?

The image list should show both shiptrack:naive and shiptrack:prod. The production image should be smaller because its runtime stage excludes the Go toolchain.

After a few seconds, the container list should report healthy for shiptrack. Docker is now checking the running API through /healthcheck.

You have closed both gaps from the first image. The runtime is smaller and Docker can confirm that ShipTrack is serving requests.

Container still starting or unhealthy?

A starting status can appear briefly while Docker waits for the first successful probe. Run the container listing again after the health-check interval.

If the status becomes unhealthy, confirm the browser can still reach the health endpoint. Recheck the URL in cmd/healthcheck/main.go if the endpoint does not respond.

Use this prompt for targeted help: help me diagnose why the ShipTrack production container is not healthy

Your production image is test-gated, health-checked, and stripped of its build toolchain. Next, you will use GitHub Actions to build the same image automatically and publish successful main-branch deliveries.

Create the Delivery Workflow

Your production Docker image now passes its smoke test. Docker reports the running container as healthy.

That image still depends on you to build it. A production delivery path needs every proposed change checked automatically.

GitHub Actions moves the build gate into GitHub. Successful main-branch builds publish an image to GitHub Packages Container registry.

In this step, get ready to:
  • Define the automated container delivery workflow.
  • Create the public shiptrack-api repository.
  • Publish the main branch to trigger the delivery workflow.
Create the workflow file

A workflow is a YAML file that defines when automation runs. This workflow becomes the project's CI/CD gate.

Why GitHub Container registry?

GitHub Container registry accepts the repository-scoped GITHUB_TOKEN. The workflow can publish without a separate registry password.

Docker Hub would require another registry account. Keeping the repository workflow with its package creates a shorter delivery path.

  • Select the shiptrack-api workspace in the VS Code Explorer sidebar.
  • Use the folder creation control in the Explorer toolbar to create .github.
  • Select the new .github folder.
  • Use the folder creation control to create workflows inside .github.
  • Select the new workflows folder.
  • Use the file creation control to create container-delivery.yml inside .github/workflows.
  • Paste this workflow into the empty file:
name: Container delivery

on:
  pull_request:
  push:
    branches: ["main"]

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    steps:
      - name: Log in to GitHub Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build, test, and publish
        uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
        with:
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

What does this workflow do?

  • The on section starts a build for pull requests. It also starts one for pushes to main.
  • The permissions section grants read access to repository contents. It grants write access to packages.
  • The login step runs only outside pull requests. It authenticates with the short-lived GITHUB_TOKEN.
  • The build action runs the existing Dockerfile. Its push expression publishes only non-pull-request builds.
  • Both Docker actions use full commit SHAs. This locks each workflow dependency to reviewed code.
  • Save .github/workflows/container-delivery.yml.

You should see container-delivery.yml nested under .github/workflows in Explorer.

Workflow file in the wrong place?

Check that the full path is .github/workflows/container-delivery.yml. GitHub only discovers workflow files inside that directory.

Keep every nested YAML line aligned with spaces. A misplaced indentation level can stop the workflow from loading.

help me check my workflow path and YAML indentation

✔️ Awesome, I've got everything!

Great. Save .github/workflows/container-delivery.yml before creating the remote repository.

ⓧ I'd like to double check the full code

Your new workflow file should match this exactly.

name: Container delivery

on:
  pull_request:
  push:
    branches: ["main"]

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    steps:
      - name: Log in to GitHub Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build, test, and publish
        uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
        with:
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
Create the public repository

The workflow needs a remote repository where GitHub can detect each push. That repository also owns the package created by the workflow.

Public visibility makes committed files visible to everyone. The ShipTrack project contains no committed credentials.

  • Sign in to GitHub in your browser.
  • Select New repository from GitHub's repository menu.
  • Enter shiptrack-api in the Repository name field.
  • Select Public as the repository visibility.
  • Leave the README option unselected.
  • Leave the .gitignore option unselected.
  • Leave the license option unselected.
  • Select Create repository.

Good progress. Your empty shiptrack-api repository is ready to receive the existing project without a merge conflict.

Push and verify the delivery

Git records the complete project as a commit. Pushing that commit to main gives GitHub Actions the event it needs.

  • Create the local repository history in the integrated PowerShell terminal by running these commands:
git init -b main
git add .
git commit -m "Build ShipTrack delivery pipeline"

What do these commands do?

  • git init -b main initializes the repository with main as its first branch.
  • git add . stages every project file for the first commit.
  • git commit -m "Build ShipTrack delivery pipeline" records the staged project as one delivery-ready snapshot.

You should see a new root commit containing the Go module, Docker files, application code, and workflow.

Commit did not complete?

If Git asks for your identity, configure the name and email associated with your GitHub account. Run the commit command again afterward.

help me finish my first Git commit

The first push may open a browser sign-in, so the authentication prompt is expected.

  • Replace <owner> in the remote URL below with the repository owner shown by GitHub.

Before you push, do you expect this main branch event to build only or to publish an image too?

  • Connect the remote repository and publish the branch by running these commands:
git remote add origin https://github.com/<owner>/shiptrack-api.git
git push -u origin main

What do these commands do?

  • git remote add origin connects the local repository to the new GitHub repository.
  • git push -u origin main publishes the commit. It also sets the upstream branch for later pushes.
  • The push event starts Container delivery. This event permits the workflow to authenticate and publish latest.

You should see Git confirm that main now tracks the remote branch.

The first workflow run can take a few minutes while its runner builds the image. A moving progress indicator during this wait is expected.

  • Return to the shiptrack-api repository in GitHub.
  • Select the Actions tab.
  • Open the Container delivery workflow run created by the push.
  • Open the Build, test, and publish step.

You should see the step complete successfully. Its build log includes API smoke test passed.

  • Return to your GitHub account page.

You should see a shiptrack-api package for the account. That package contains the published latest image.

That is your delivery gate working. GitHub built the production Dockerfile before publishing the container artifact.

Workflow missing or failing?

Confirm the workflow file is committed at .github/workflows/container-delivery.yml. Confirm the pushed branch is main.

If the push failed, check that the owner in the remote URL matches the repository page. Complete GitHub authentication when prompted.

help me diagnose my Container delivery run

Your source now moves through an automated build gate and into a container registry. Next, you will pull that published artifact and prove it behaves like the local production image.

Pull and Run the Published Artifact

Your GitHub Actions workflow now turns each successful main-branch build into an image in the GitHub Container Registry. That proves your automation can produce the artifact.

A delivery pipeline earns trust when its published image can replace your local build. The final check runs the downloaded image without rebuilding the source.

Matching API output proves the application behaves consistently. A healthy status from Docker confirms the production health check travelled with the image.

In this step, get ready to:
  • Make the published shiptrack-api package public.
  • Pull the public latest image after removing the local production tag.
  • Verify the pulled image through its order endpoint and health status.
Make the package public

The first container package is private by default even when its repository is public. Public visibility allows the image to be pulled without registry authentication.

Check Before You Confirm

Changing this package to public cannot be reversed. Your package contains only the project image that you intend to share.

  • In GitHub from earlier, navigate to the package page for shiptrack-api.
  • Record the owner segment shown on the package page as <owner>.
  • Select Package settings.
  • Scroll to Danger Zone.
  • Select Change visibility.
  • Select Public.
  • Enter shiptrack-api in the package-name confirmation field.
  • Select I understand the consequences, change package visibility.

The package page now shows Public. Your published image is ready for an unauthenticated pull.

Pull the published image

The latest tag can point to newer content after another publish. The pull output also provides an immutable digest that identifies the exact image content you downloaded.

Removing the local container and production tag only clears local Docker state. Your published package remains available in the registry.

  • Replace the local production build with the published artifact by running these commands in the integrated PowerShell terminal:
docker rm -f shiptrack
docker image rm shiptrack:prod
docker pull ghcr.io/[[OWNER="<owner>"]]/shiptrack-api:latest

What Do These Commands Do?

  • The first command force-removes the running shiptrack container so its name and port can be reused.
  • The second command removes the local shiptrack:prod tag.
  • The final command downloads ghcr.io/<owner>/shiptrack-api:latest from the public package.

The pull output includes a line beginning with Digest: sha256:.... This value identifies the exact published image on your workstation.

  • Record the complete digest as sha256:<first-digest>.

Having Trouble Pulling the Image?

Confirm the package page shows Public. A private package blocks an unauthenticated pull.

Check that <owner> matches the owner segment shown on the package page.

Ask for help with the pull if the package is public but the download still fails: help me troubleshoot why Docker cannot pull my public GHCR image.

Run and verify the registry artifact

The downloaded image carries the same API binary and health-check binary produced by the delivery workflow. Running it closes the path from committed source to a working registry artifact.

  • Start the pulled image by running this command:
docker run --name shiptrack -d -p 127.0.0.1:8080:8080 ghcr.io/[[OWNER="<owner>"]]/shiptrack-api:latest

What Does This Command Do?

  • The --name shiptrack option gives the new container the project name.
  • The -d option runs the container in the background.
  • The -p 127.0.0.1:8080:8080 option exposes the API only through your workstation's loopback address.

The health check needs a few seconds to complete its first successful probe. That short wait confirms the check is running inside the pulled container.

  • Wait a few seconds for the container health check to run.

Before you check, what behavior do you expect from the registry image:

  • Open http://localhost:8080/orders/ORD-1001 in your browser.

You will see {"id":"ORD-1001","status":"in_transit","eta":"2026-11-05"} in the browser. This is the same order response produced by your local production image.

  • Check the running container's health status by running this command:
docker ps

What Does This Check Prove?

The container list identifies the image behind each running container. Its status also reports the result of the health check embedded in the published image.

The shiptrack row uses ghcr.io/<owner>/shiptrack-api:latest. Its status includes healthy.

Missing the JSON or Healthy Status?

Confirm the shiptrack row is present before checking the browser. A missing row means the container stopped after startup.

Confirm no other container is holding port 8080. The published image needs that host port for the browser check.

Ask for help if the container remains unhealthy: help me diagnose why the pulled ShipTrack container is not healthy.

You have closed the delivery loop. Your workstation now runs the tested registry artifact with the same order response and health behavior as the local production build.

Secret mission

Roll Back by Immutable Digest

Publish a second image without changing the ShipTrack files. Then recover the earlier production image by running its immutable digest after the `latest` tag moves.

Clean Up Your Resources

Clean Up Your Resources

Public project resources cost $0 under GitHub's public repository and package policies. Choose the cleanup option that matches what you want to keep.

Resources you used:

  • Public GitHub repository at https://github.com/<owner>/shiptrack-api.git.
  • Public GitHub Packages Container registry package at ghcr.io/<owner>/shiptrack-api.
  • Running Docker container named shiptrack-rollback.
  • Local image tagged shiptrack:naive.
  • Local published image tagged ghcr.io/<owner>/shiptrack-api:latest.
  • Local rollback image with digest sha256:<first-digest>.
  • Local shiptrack-api workspace containing the application source and delivery workflow.

Keep everything running

No action is needed. Choose this option if you want ShipTrack to remain available as a delivery and rollback demo.

  • Your public repository remains available at https://github.com/<owner>/shiptrack-api.git.
  • Your public container package remains available at ghcr.io/<owner>/shiptrack-api.
  • Your shiptrack-rollback container keeps serving the earlier immutable image on port 8080.
  • Your local images remain available for future container comparisons.
  • Your shiptrack-api workspace remains ready for another commit or workflow run.

Everything stays available without cloud charges while the repository and package remain public.

Pause - I'll come back to this later

Pausing locally frees the memory used by the running rollback container. Your source files and published artifacts remain intact.

  • Switch back to Docker Desktop.
  • Select the container view.
  • Stop shiptrack-rollback with its stop control.
  • Remove shiptrack-rollback with its delete control.

You should see no ShipTrack container running. Your local images remain available for the next run.

  • Keep the shiptrack-api workspace on your computer.
  • Keep the public repository available on GitHub.
  • Keep the public container package available in GitHub Packages Container registry.

No workflow runs occur until you push another commit or open a pull request.

Delete - I don't want to use this again

Deleting these resources is permanent. Keep them instead if you want ShipTrack to stay available at no cost.

  • Switch back to Docker Desktop.
  • Select the container view.
  • Stop shiptrack-rollback with its stop control.
  • Remove shiptrack-rollback with its delete control.

You should see no ShipTrack container in the container view.

  • Select the image view in Docker Desktop.
  • Remove the image tagged shiptrack:naive.
  • Remove the image tagged ghcr.io/<owner>/shiptrack-api:latest.
  • Remove the earlier image identified by sha256:<first-digest>.

Docker Desktop should no longer list any local ShipTrack images.

  • Open the package page for ghcr.io/<owner>/shiptrack-api on GitHub.
  • Select Package settings.
  • Scroll to Danger Zone.
  • Use the package deletion control.
  • Enter the package name shown by GitHub in the confirmation field.
  • Confirm the package deletion.

The public package page should stop offering the ShipTrack images for download.

  • Open the repository page at https://github.com/<owner>/shiptrack-api.git.
  • Select Settings.
  • Scroll to the repository's Danger Zone.
  • Use the repository deletion control.
  • Enter the confirmation text shown by GitHub.
  • Confirm the repository deletion.

The public repository should no longer display the ShipTrack source or its workflow history.

  • Return to the open shiptrack-api workspace in Visual Studio Code.
  • Reveal the shiptrack-api folder in File Explorer from the Explorer sidebar.
  • Close Visual Studio Code.
  • Select the shiptrack-api folder in File Explorer.
  • Press Shift+Delete to remove the folder permanently.
  • Confirm the folder deletion.

File Explorer should no longer show the local shiptrack-api workspace.

Nice Work!

Nice Work!

You did it! ShipTrack now runs as a tested production Docker container that reports its own health. You also published the exact artifact through GitHub Actions to GitHub Container Registry.

You've learned how to:

  • Built a JSON order-tracking API inside Docker without installing Go locally.
  • Replaced a naive single-stage image with a smaller multi-stage image. Added a build-time smoke test plus an executable health check. Ran the final image as a numeric non-root user.
  • Created an automated container delivery workflow that gates proposed changes. Published successful main-branch images. Pulled the registry artifact to prove it works beyond the local build.
  • Completed the Secret Mission by rolling ShipTrack back to an earlier image using its immutable digest after the mutable latest tag moved.

Ready to quiz yourself?