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.
| Aspect | Bare-VM (Fundamentals) | Containerized |
|---|---|---|
| Binary location | /home/trainee/customerapp/customerapp | /customerapp inside the container |
| Configuration | Hardcoded in source code | Environment variables passed at docker run |
| Static files | ~/customerapp/static/ on the filesystem | Copied into the image at build time |
| DB connection | Always 192.168.56.11 | Whatever DB_HOST env var says |
| Process manager | systemd | Docker (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():
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():
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.goreads 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:
# ── 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:
COPY go.mod go.sum— dependency files change rarely. Docker caches this layer.RUN go mod download— downloads dependencies. Reused from cache as long asgo.mod/go.sumhaven't changed.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/Dockerfileexists 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.latestis a convenience for local development — it tells you nothing about which version is actually running. In production,latestleads to "it works on my machine" problems because different hosts may have pulledlatestat different times.
Checkpoint:
docker imagesshowscustomerapp:v1at ~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 customerappshows 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.
.git
.gitignore
README.md
*.md
customerapp
.git— the repository history is not needed in the imagecustomerapp— 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-cacheis 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}}"
| Image | Tag | Size |
|---|---|---|
| customerapp | v1 | ~20 MB |
| golang | 1.22-alpine | ~250 MB |
| alpine | 3.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:
- PostgreSQL is running:
ssh db-server "sudo systemctl status postgresql" pg_hba.confallows connections from192.168.56.12postgresql.confhaslisten_addresses = '*'- 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:
- DNS resolution:
docker run --rm alpine nslookup proxy.golang.org - If behind a corporate proxy, pass proxy env vars:
docker build --build-arg HTTP_PROXY=... -t customerapp:v1 .
9. What You Have Now
| Capability | Verification Command |
|---|---|
| App reads config from environment variables | DB_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 app | cat ~/customerapp/Dockerfile |
| Built and tagged container image | docker images customerapp |
| Container runs and connects to PostgreSQL | curl http://localhost:8080/health |
| Layer caching optimizes rebuilds | Change main.go, rebuild, observe CACHED layers |
.dockerignore excludes unnecessary files | cat ~/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.