Back to Blog
godockerloggingauthenticationhtmx

The Boring First Week: What Broke While Setting Up a Small Go Service

Config, auth, logging and Docker for a small Go app. Each one failed in a way no tutorial warned me about.

In short: Configuration, authentication, logging and Docker for a small Go service. Each one first failed silently: the kind of failure that reaches production looking healthy. The lesson I now apply to every service: when a component is misconfigured, it must refuse to start or say so loudly.

Before any distributed-systems work, my budget app needed the parts every service needs: configuration, login, logs, and a container. I expected that week to be dull. Instead, every one of the five pieces broke in a way that taught me something I now check for by default.

1. Configuration that fails loudly

The app reads its settings from environment variables (PORT, DB_PATH, LOG_LEVEL), the "12-factor" way: the same binary runs anywhere, and nothing environment-specific lives in code.

My first version had a quiet flaw: if PORT was set to something invalid, it fell back to the default. PORT=abc produced a server happily listening on 8080, and no hint that my setting had been ignored.

The rule I use now:

  • Missing value → use a sensible default.
  • Present but invalid value → refuse to start, with a clear error.
port, err := strconv.Atoi(portStr)
if err != nil {
	return nil, fmt.Errorf("invalid PORT value: %v", err)
}
if port < 1 || port > 65535 {
	return nil, fmt.Errorf("PORT must be between 1 and 65535")
}

A service that starts with the wrong settings is worse than one that doesn't start, because the first one looks healthy.

2. Passwords and sessions

Passwords are stored as bcrypt hashes, never as text. Sessions are 32 random bytes from crypto/rand, hex-encoded, held in a cookie marked HttpOnly. A middleware checks the cookie before any handler that changes data.

The surprise came from HTMX, not from security. HTMX swaps fragments of the page. After logging in, the fragment that received the response updated, but the rest of the page, rendered with {{if .Authenticated}}, still showed the signed-out forms. The fix was one response header telling HTMX to reload the whole page:

w.Header().Set("HX-Refresh", "true")

Partial updates are HTMX's strength, and exactly why auth state needs a full refresh.

3. Logs you can search

I switched from log.Println strings to Go's log/slog with a JSON handler, and gave every request an ID that appears on every log line it produces:

{"level":"INFO","msg":"request completed","request_id":"a084354f","method":"POST","path":"/sync","status":200,"duration_ms":0}

Two bugs appeared along the way:

  • Empty request IDs. The ID was overwritten while being passed through the request context. The fix: generate it once, fall back to "unknown" if generation fails, store it once.
  • Startup failures that looked like success. Returning from main() after logging an error exits with code 0. Anything supervising the process (Docker, CI, a service manager) sees a clean exit. Fatal startup errors now log and call os.Exit(1).

4. Docker

A multi-stage build compiles in a full Go image and runs in a slim Debian image. Three things broke:

  1. The health check couldn't run. It called wget, which the slim runtime image doesn't include. The app was fine; Docker marked it unhealthy anyway. Tools your health check uses are runtime dependencies.
  2. The settings didn't arrive. The compose file set APP_PORT; the code read PORT. Nothing failed. The app just used its defaults (see point 1: a quiet fallback is a trap).
  3. Unreachable from the host. The server listened on 127.0.0.1 inside the container, which only accepts connections from inside the container. Listening on :8080 (all interfaces) let Docker's port mapping reach it.

5. Unit tests pass; boundaries break

Every package had passing tests, and still the full flow had problems at the seams: auth middleware blocking validation tests that didn't log in first, environment variable names that didn't match between compose and code, a bind address that only mattered inside a container. The final step of the week was running the real flow end to end: register, log in, add, delete, log out, locally and in Docker, and reading the logs to confirm each step.

Where this shows up in real systems

  • Configuration mistakes are a common cause of outages. Validating config at startup, and refusing to boot on bad values, is some of the cheapest reliability work available.
  • Exit codes and health checks are a contract with the platform. Kubernetes, ECS and systemd restart and route traffic based on them. If they report "healthy" when they aren't, self-healing quietly stops working.
  • Structured logs with request IDs are what turn "something failed around 14:00" into the exact request, user action and error, which is the difference between an incident lasting minutes and one lasting hours.

What I took away

None of this was advanced, and all of it would have hurt later. Every one of these failures was silent: a fallback, a zero exit code, a swallowed setting, a health check that lied. The habit I got from Week 1 is to ask of every component: when this is misconfigured, does it tell me, or does it quietly do something else?

Next: Vector Clocks, Explained With a Budget.


About this series: I'm learning distributed systems by building a real application, with an AI assistant as coach and pair programmer. It explains, reviews and sometimes writes code; I verify every claim against tests and measurements, and every bug and number here is real.

I write about building reliable software in Go, from the code up. If you're working on similar problems, I'd like to hear from you: get in touch or connect with me on LinkedIn.