Across five parts, bookmarkd grew from a bare Fiber router into a tested, observable API with real accounts and a PostgreSQL-backed store. All of it has been running with go run or a manually built binary on a single Ubuntu host. This final part packages the whole thing into a Docker image, runs it alongside PostgreSQL with Docker Compose, and puts Nginx in front of it with a real TLS certificate, which is how this kind of service actually gets deployed in practice.
This continues from Testing a Go REST API with Fiber and Testcontainers on Ubuntu. Everything built there, the unit tests, the route tests, and the PostgreSQL integration tests, keeps working unchanged; this part only changes how the application is packaged and run.
This tutorial is for developers who have bookmarkd complete through Part 5 and want to take it from a binary running on a single host to a containerized service with a reverse proxy, TLS, and a documented restart story. By the end, docker compose up -d will bring up the entire stack, and https://bookmarkd.example.com will serve real traffic.
Conceptual Overview
A multi-stage Docker build uses more than one FROM instruction in a single Dockerfile, where an early stage compiles the application and a later stage copies only the finished binary into a much smaller final image. This matters for Go specifically because the compiler, source code, and module cache used to build the binary have no reason to exist in the image that actually runs it.
A distroless or static base image contains only what a statically linked binary needs to run, no shell, no package manager, no C library beyond what is statically linked in. This shrinks the attack surface considerably: there is no shell for an attacker to get into even if they find a way to execute code inside the container, because there simply is no shell there.
Docker Compose describes a group of containers, their networking, and their startup dependencies in one YAML file, so the whole stack (the API, PostgreSQL, and their shared network) starts and stops as a unit with one command instead of several docker run invocations kept in sync by hand.
Nginx in front of the API serves two purposes here: it terminates TLS so the Go application never has to handle certificates itself, and it gives you a stable place to add rate limiting, request size limits, or a second backend later without touching the API’s code at all.
Prerequisites
Before starting, make sure you have:
- The
bookmarkdproject from Part 5, onapi-01at10.20.0.51, with a workinggo test ./.... - Docker Engine and the Docker Compose plugin installed. See Getting Started with Docker and Docker Compose on Ubuntu if not.
- A domain name pointed at
api-01’s public IP, used here asbookmarkd.example.com. Substitute your own. - Familiarity with the basics of Nginx as a reverse proxy; see How to Run Nginx in Docker with Docker Compose on Ubuntu for background.
Step 1: Write the Multi-Stage Dockerfile
Create Dockerfile in the project root:
# --- build stage ---
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-o /out/bookmarkd \
./cmd/api
# --- final stage ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/bookmarkd /bookmarkd
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/bookmarkd"]
A few choices here are deliberate. CGO_ENABLED=0 disables cgo so the binary links no dynamic C libraries, which is required for it to run on a distroless base with no C library present. -ldflags="-s -w" strips debug symbols and the DWARF table, shrinking the binary by roughly a third with no effect on how it runs. Copying go.mod and go.sum before the rest of the source, then running go mod download, lets Docker’s layer cache reuse the downloaded modules on every rebuild where dependencies did not change, so only the final COPY . . and go build layers rebuild when you edit application code. The final image runs as nonroot, a user baked into the distroless image specifically for this purpose, so the process never runs as root even inside its own container.
Create .dockerignore so build context stays small and local artifacts never leak into the image:
.git
bookmarkd
*.test
.env
Build and inspect the image size:
docker build -t bookmarkd:latest .
docker images bookmarkd
REPOSITORY TAG IMAGE ID CREATED SIZE
bookmarkd latest a1b2c3d4e5f6 10 seconds ago 18.4MB
Eighteen megabytes for a full REST API, including its PostgreSQL driver, is what a static binary on a distroless base buys you; the equivalent built on golang:1.25 directly, without the multi-stage split, would be well over 900MB.
Step 2: Scan the Image for Vulnerabilities
Before running anything built, scan it. If you have not used Trivy before, see Scan Container Images for Vulnerabilities with Trivy on Ubuntu for the full setup; the short version:
trivy image --severity HIGH,CRITICAL bookmarkd:latest
A distroless base with no package manager and a statically linked Go binary tends to produce a very short report, since most CVE databases track OS packages that simply are not present in this image. Any findings that do show up are worth reading; they will point at the Go standard library or a direct dependency, both of which are fixed by updating go.mod and rebuilding, not by patching an OS package.
Step 3: Compose the Full Stack
Create docker-compose.yml in the project root:
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: bookmarkd
POSTGRES_USER: bookmarkd
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- db_data:/var/lib/postgresql/data
secrets:
- db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bookmarkd -d bookmarkd"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
restart: unless-stopped
environment:
DATABASE_URL: postgresql://bookmarkd:${DB_PASSWORD}@db:5432/bookmarkd?sslmode=disable
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy
expose:
- "8080"
volumes:
db_data:
secrets:
db_password:
file: ./secrets/db_password.txt
depends_on with condition: service_healthy is what makes this reliable: without it, Compose starts both containers at roughly the same time, and bookmarkd would fail its first connection attempt because PostgreSQL is still initializing. With the healthcheck in place, api only starts once pg_isready succeeds inside the db container. The API container also has no published port of its own; expose makes port 8080 reachable to other containers on the same Compose network, but not to the host or the internet, since Nginx is the only thing that should be reachable directly.
Create the password file and a .env file for the values Compose interpolates:
mkdir -p secrets
openssl rand -base64 24 > secrets/db_password.txt
chmod 600 secrets/db_password.txt
cat > .env <<EOF
DB_PASSWORD=$(cat secrets/db_password.txt)
JWT_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 .env
Bring the stack up:
docker compose up -d
docker compose ps
NAME IMAGE STATUS
bookmarkd-api-1 bookmarkd-api Up 5 seconds
bookmarkd-db-1 postgres:16-alpine Up 10 seconds (healthy)
Run the goose migrations from Parts 2 and 3 against the containerized database before the API is useful:
goose -dir db/migrations postgres "postgresql://bookmarkd:$(cat secrets/db_password.txt)@localhost:5432/bookmarkd?sslmode=disable" up
Step 4: Put Nginx in Front with TLS
Install Nginx directly on the host, rather than in a container, so Certbot’s standard Nginx plugin can manage certificates without extra volume wiring; either approach works, but this keeps the TLS setup identical to the one covered in How to Secure Nginx with Let’s Encrypt SSL Using Certbot on Ubuntu.
sudo apt update
sudo apt install -y nginx
Create /etc/nginx/sites-available/bookmarkd:
server {
listen 80;
server_name bookmarkd.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Since the api container only exposes port 8080 to other containers, add a port mapping in docker-compose.yml so Nginx on the host can reach it, binding to localhost only so it is never reachable directly from outside the machine:
api:
ports:
- "127.0.0.1:8080:8080"
Apply it and enable the site:
docker compose up -d
sudo ln -s /etc/nginx/sites-available/bookmarkd /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Now request a certificate with Certbot:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d bookmarkd.example.com
Certbot rewrites the server block to redirect port 80 to 443 and adds the certificate paths automatically. Confirm it works:
curl -s https://bookmarkd.example.com/livez
{"status":"ok"}
Step 5: Log Rotation and Restart Policy
Docker’s default logging driver keeps container logs on disk indefinitely unless configured otherwise, which on a long-running service eventually fills the disk. Add log rotation to both services in docker-compose.yml:
api:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Apply the same block under db. This caps each service at 30MB of retained logs (three files of 10MB), rotating older entries out automatically. The restart: unless-stopped policy already set on both services tells Docker to restart a crashed container automatically and to bring containers back up after a host reboot, but not to restart a container someone explicitly stopped with docker compose stop, which is the right default for a service like this one.
Common Mistakes and Troubleshooting
Image builds successfully but the container exits immediately. Distroless images have no shell, so docker exec -it <container> sh will not work for debugging. Check logs instead with docker compose logs api; a common cause is a missing DATABASE_URL or JWT_SECRET environment variable, since the application calls log.Fatal when either is unset.
api container keeps restarting in a loop. Confirm depends_on: condition: service_healthy is present and that the db service’s healthcheck actually passes; run docker compose ps to see whether db ever reaches the healthy state. If it never does, check docker compose logs db for a PostgreSQL startup error, often a bad POSTGRES_PASSWORD_FILE path.
Certbot fails with “connection refused” during the HTTP-01 challenge. This usually means port 80 is not actually reachable from the internet, either because a cloud firewall blocks it or DNS for bookmarkd.example.com does not yet point at api-01’s public IP. Confirm both with dig bookmarkd.example.com and a firewall rule check before retrying.
Nginx returns 502 Bad Gateway. The API container is not listening where Nginx expects. Confirm docker compose ps shows api as running and that the port mapping in docker-compose.yml actually publishes 127.0.0.1:8080:8080; a container that only exposes a port to the Compose network, without a ports mapping, is invisible to a host-level Nginx process.
Best Practices
- Never bake secrets into the image.
JWT_SECRETand the database password are passed as environment variables and a Docker secret, notARGorENVinstructions in the Dockerfile, which would otherwise be visible in the image’s layer history. - Pin your base image tags, both
golang:1.25andpostgres:16-alpine, the same way the Nginx image tagging guide recommends, so a rebuild next month does not silently pull a different major version. - Keep the database off the public network entirely.
dbin this Compose file has noportsmapping at all; it is reachable only fromapiover the internal Compose network, which is the correct default for any database backing a service like this. - Scan images on every build, not just once. Wire the
trivy imagecommand from Step 2 into the same GitHub Actions workflow built in Part 5, so a newly disclosed CVE in a dependency gets caught on the next push rather than discovered manually months later. - Automate certificate renewal. Certbot installs a systemd timer that handles this automatically on Ubuntu; confirm it exists with
systemctl list-timers | grep certbotrather than assuming it is there.
Conclusion
bookmarkd is now a complete, deployable service: a Go and Fiber API in an 18MB distroless container, PostgreSQL running alongside it under Docker Compose with a healthcheck-gated startup order, Nginx terminating TLS in front of both, log rotation keeping disk usage bounded, and a Trivy scan checking the image on every build. Every piece built across this series, the routing from Part 1, the database layer from Part 2, authentication from Part 3, observability from Part 4, and the test suite from Part 5, is running exactly as designed, just packaged the way a real production deployment expects.
From here, natural next steps include moving this Compose setup onto a proper orchestrator like Kubernetes once you need more than one API instance, adding a CDN or WAF in front of Nginx for a public-facing service, or extending the observability from Part 4 with a Grafana dashboard built specifically around the metrics this API now exposes.