Skip to main content

Module 02 — Containerize the App

In Module 01 you built a simple Go image from scratch. Now you containerize the actual Customer Information App from the Fundamentals track — the Go backend that connects to PostgreSQL and serves the frontend.

The key challenge: the app hardcodes the database host. Containers need to be portable, so you will make the app read its config from environment variables before writing the Dockerfile.

Everything runs on app-server (192.168.56.12) where Docker is already installed.


1. What Changes for Containers

Before touching any code, understand what is different when an app runs inside a container versus directly on a VM.

AspectBare-VM (Fundamentals)Containerized
Binary location/home/trainee/customerapp/customerapp/customerapp inside the container
ConfigurationHardcoded in source codeEnvironment variables passed at docker run
Static files~/customerapp/static/ on the filesystemCopied into the image at build time
DB connectionAlways 192.168.56.11Whatever DB_HOST env var says
Process managersystemdDocker (restart policies)

The principle behind this shift: environment variables replace hardcoded config. This is one of the Twelve-Factor App principles — store config in the environment so the same image can run anywhere without rebuilding.

What stays the same: the Go source code (with a small tweak), PostgreSQL on db-server, and the app's behavior.


2. Make the App Container-Ready

The current main.go has this hardcoded connection string:

connStr := "host=192.168.56.11 port=5432 user=appuser password=apppassword123 dbname=customerdb sslmode=disable"

You need to replace each value with an environment variable that falls back to the original default, so the app works both on bare metal (no env vars set) and in a container (env vars provided at runtime).

2.1 Edit main.go

SSH into app-server and open the file:

ssh app-server
cd ~/customerapp

Add the "os" import to the import block and create a helper function near the top of the file, before main():

~/customerapp/main.go
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"

_ "github.com/lib/pq"
)

// getEnv returns the value of an environment variable, or a fallback default.
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}

Then replace the hardcoded connStr line in main():

~/customerapp/main.go (inside func main)
dbHost := getEnv("DB_HOST", "192.168.56.11")
dbPort := getEnv("DB_PORT", "5432")
dbUser := getEnv("DB_USER", "appuser")
dbPassword := getEnv("DB_PASSWORD", "apppassword123")
dbName := getEnv("DB_NAME", "customerdb")

connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
dbHost, dbPort, dbUser, dbPassword, dbName,
)

Note: The fallback values match the originals, so existing bare-metal deployments keep working without any configuration changes.

2.2 Verify on bare metal

Rebuild and restart to confirm the change is backward-compatible:

go build -o customerapp .
sudo systemctl restart customerapp

Test the health endpoint from your Mac:

curl http://192.168.56.12:8080/health

Expected output:

{"database":"connected","status":"healthy"}

The app still works — it fell back to the hardcoded defaults because no env vars are set.

Checkpoint: main.go reads config from environment variables with sensible fallbacks. The app runs identically to before on bare metal.


3. Write the Dockerfile

Now you package the app into an image. You will use a multi-stage build — same technique from Module 01, but with a real application that has dependencies and static files.

3.1 Create the Dockerfile

cd ~/customerapp

Create the file:

~/customerapp/Dockerfile
# ── Stage 1: Build ──────────────────────────────
FROM golang:1.22-alpine AS builder
WORKDIR /app

# Copy dependency files first (changes rarely → cached layer)
COPY go.mod go.sum ./
RUN go mod download

# Copy source code (changes often → rebuilds from here)
COPY . .
RUN go build -o customerapp .

# ── Stage 2: Runtime ────────────────────────────
FROM alpine:3.19
WORKDIR /

COPY --from=builder /app/customerapp /customerapp
COPY --from=builder /app/static /static

# Default env vars (can be overridden at runtime)
ENV DB_HOST=192.168.56.11
ENV DB_PORT=5432
ENV DB_USER=appuser
ENV DB_PASSWORD=apppassword123
ENV DB_NAME=customerdb

EXPOSE 8080

CMD ["/customerapp"]

3.2 Why this order matters

Each Dockerfile instruction creates a layer. Docker caches layers and reuses them if nothing changed. The order is deliberate:

  1. COPY go.mod go.sum — dependency files change rarely. Docker caches this layer.
  2. RUN go mod download — downloads dependencies. Reused from cache as long as go.mod/go.sum haven't changed.
  3. COPY . . — your source code. This changes with every edit, so it goes last.

If you had put COPY . . before go mod download, every source code change would re-download all dependencies — wasting minutes on each build.

3.3 The runtime stage

The second FROM starts a fresh alpine:3.19 image (~7 MB). It copies only:

  • The compiled binary (/customerapp)
  • The static frontend files (/static)

The entire Go toolchain, source code, and intermediate build files from stage 1 are discarded. This is why the final image is ~20 MB instead of ~250 MB.

Checkpoint: ~/customerapp/Dockerfile exists with a two-stage build — builder compiles the binary, runtime copies only what's needed.


4. Build and Tag the Image

4.1 Build the image

docker build -t customerapp:v1 .

Watch the output — Docker executes each instruction:

[+] Building ...
=> [builder 1/5] FROM golang:1.22-alpine
=> [builder 2/5] COPY go.mod go.sum ./
=> [builder 3/5] RUN go mod download
=> [builder 4/5] COPY . .
=> [builder 5/5] RUN go build -o customerapp .
=> [stage-1 1/3] FROM alpine:3.19
=> [stage-1 2/3] COPY --from=builder /app/customerapp /customerapp
=> [stage-1 3/3] COPY --from=builder /app/static /static
=> => naming to docker.io/library/customerapp:v1

4.2 Check the image

docker images customerapp

Expected output:

REPOSITORY    TAG       IMAGE ID       CREATED          SIZE
customerapp v1 ... 5 seconds ago ~20MB

Compare that to the Go build image:

docker images golang
REPOSITORY   TAG            SIZE
golang 1.22-alpine ~250MB

Your app image is roughly 12x smaller than the build toolchain.

4.3 Tag as latest

docker tag customerapp:v1 customerapp:latest

Tip: Always use explicit version tags (v1, v2) for deployments. latest is a convenience for local development — it tells you nothing about which version is actually running. In production, latest leads to "it works on my machine" problems because different hosts may have pulled latest at different times.

Checkpoint: docker images shows customerapp:v1 at ~20 MB. The multi-stage build discarded the Go toolchain.


5. Run the Containerized App

5.1 Stop the systemd service

The bare-metal app is still running on port 8080. Stop it first:

sudo systemctl stop customerapp

5.2 Run the container

docker run -d \
-p 8080:8080 \
--name customerapp \
-e DB_HOST=192.168.56.11 \
-e DB_USER=appuser \
-e DB_PASSWORD=apppassword123 \
-e DB_NAME=customerdb \
customerapp:v1
  • -d — detached mode (runs in background)
  • -p 8080:8080 — maps host port 8080 to container port 8080
  • --name customerapp — gives the container a readable name
  • -e KEY=VALUE — sets environment variables inside the container

5.3 Verify the container is running

docker ps

Expected output:

CONTAINER ID   IMAGE             COMMAND          STATUS         PORTS                    NAMES
a1b2c3d4e5f6 customerapp:v1 "/customerapp" Up 5 seconds 0.0.0.0:8080->8080/tcp customerapp

5.4 Check the logs

docker logs customerapp

Expected output:

2024/01/01 12:00:00 Connected to database
2024/01/01 12:00:00 Server starting on :8080

If you see Connected to database, the container successfully reached PostgreSQL on db-server.

5.5 Test the endpoints

Health check:

curl http://localhost:8080/health
{"database":"connected","status":"healthy"}

Login and test the API:

# Login and save cookie
curl -c cookies.txt -X POST http://localhost:8080/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'

# List customers
curl -b cookies.txt http://localhost:8080/api/customers

The responses should be identical to what you got from the bare-metal version in the Fundamentals track.

5.6 Visit the frontend

Open a browser on your Mac and go to http://192.168.56.12:8080. The Customer Information App UI should load — the same HTML/CSS/JS frontend, now served from inside a container.

Checkpoint: The containerized app serves the same responses as the bare-VM version. docker logs customerapp shows a successful database connection.


6. Optimize the Build

6.1 Create a .dockerignore file

Just like .gitignore excludes files from git, .dockerignore excludes files from the Docker build context. This makes builds faster and prevents unnecessary files from ending up in your image.

~/customerapp/.dockerignore
.git
.gitignore
README.md
*.md
customerapp
  • .git — the repository history is not needed in the image
  • customerapp — the bare-metal binary; the container builds its own
  • *.md — documentation files

6.2 See layer caching in action

Make a trivial change to main.go — for example, change the log message:

sed -i 's/Server starting on :8080/Server listening on :8080/' main.go

Rebuild:

docker build -t customerapp:v2 .

Watch the output — the go mod download layer says CACHED:

 => CACHED [builder 2/5] COPY go.mod go.sum ./
=> CACHED [builder 3/5] RUN go mod download
=> [builder 4/5] COPY . .
=> [builder 5/5] RUN go build -o customerapp .

Only the source copy and compilation layers re-ran. The dependency download was skipped entirely because go.mod and go.sum didn't change.

6.3 Force a clean build

When debugging build issues, you can bypass the cache:

docker build --no-cache -t customerapp:v2 .

This rebuilds every layer from scratch. Use it sparingly — it's much slower.

Tip: If your build produces unexpected results (stale files, wrong versions), --no-cache is the first thing to try. Layer caching is usually a feature, but it can mask problems.


7. Image Size Analysis

Understanding where size comes from helps you make informed optimization decisions.

7.1 Inspect the layer history

docker history customerapp:v1

Each row is one layer. The SIZE column shows how much each instruction added:

IMAGE          CREATED BY                                      SIZE
... CMD ["/customerapp"] 0B
... EXPOSE 8080 0B
... ENV DB_NAME=customerdb 0B
... COPY /app/static /static (from builder) ~50kB
... COPY /app/customerapp /customerapp (from builder) ~15MB
... FROM alpine:3.19 ~7MB

The binary dominates the image. The Alpine base and static files are minimal.

7.2 Size comparison

docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
ImageTagSize
customerappv1~20 MB
golang1.22-alpine~250 MB
alpine3.19~7 MB

7.3 Why small images matter

  • Faster pulls — pulling 20 MB from a registry takes seconds, not minutes
  • Smaller attack surface — fewer packages means fewer CVEs to patch
  • Less storage — registries and hosts store less data
  • Faster scaling — new nodes pull the image faster when autoscaling kicks in

8. Troubleshooting

dial tcp 192.168.56.11:5432: connect: connection refused

The container cannot reach PostgreSQL on db-server. Verify:

  1. PostgreSQL is running: ssh db-server "sudo systemctl status postgresql"
  2. pg_hba.conf allows connections from 192.168.56.12
  3. postgresql.conf has listen_addresses = '*'
  4. The Docker default bridge network can route to the host network — by default it can, but if you've customized Docker networking, check docker network inspect bridge

Build fails on COPY static/ ./static/

The static/ directory is not in the build context. Make sure you run docker build from ~/customerapp/ (the directory containing both the Dockerfile and the static/ folder).

exec format error when running the container

The image was built for a different CPU architecture. This can happen if you pull a prebuilt image on an ARM Mac and try to run it on an x86 VM. Rebuild on the target machine or use docker build --platform linux/amd64.

App starts but API returns 500 errors

Check the container logs: docker logs customerapp. The most common cause is wrong database credentials — verify that the -e values in your docker run command match the credentials from the Fundamentals track (appuser / apppassword123 / customerdb).

go mod download fails during build

The build stage needs internet access to download Go modules. Check:

  1. DNS resolution: docker run --rm alpine nslookup proxy.golang.org
  2. If behind a corporate proxy, pass proxy env vars: docker build --build-arg HTTP_PROXY=... -t customerapp:v1 .

9. What You Have Now

CapabilityVerification Command
App reads config from environment variablesDB_HOST=127.0.0.1 ./customerapp (exits with DB error if no local DB — proves it read the var)
Multi-stage Dockerfile for the Go appcat ~/customerapp/Dockerfile
Built and tagged container imagedocker images customerapp
Container runs and connects to PostgreSQLcurl http://localhost:8080/health
Layer caching optimizes rebuildsChange main.go, rebuild, observe CACHED layers
.dockerignore excludes unnecessary filescat ~/customerapp/.dockerignore

Next up: Module 03 — Docker Networking & Volumes — connect multiple containers with bridge networks, persist data with named volumes, and use container DNS for service discovery.