Production, Deployment & Best Practices

Difficulty

Static linking is the single feature that makes Go's container story unusually simple compared to most other languages.

A minimal multi-stage Dockerfile

# Stage 1: build
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /myapp .

# Stage 2: minimal runtime image
FROM scratch
COPY --from=builder /myapp /myapp
ENTRYPOINT ["/myapp"]

The final image contains literally nothing except your compiled binary — no shell, no package manager, no OS userland at all, since scratch is an empty base image.

Why CGO_ENABLED=0 matters here

CGO_ENABLED=0 go build -o myapp .

With cgo enabled (the default when a C toolchain is available), your binary may dynamically link against the system's C library, which means it needs a compatible C library present on the target system, breaking the "just copy the binary anywhere" story. Disabling cgo forces a fully static, pure-Go binary with zero external runtime dependencies.

Comparing image sizes, roughly

Typical Node.js app image (node:18-slim base):     ~150-250 MB
Typical Python app image (python:3.12-slim base):  ~150-200 MB
Typical Go app image (scratch or distroless base):  ~10-30 MB, often just a few MB

Why this matters operationally

Smaller images pull faster (meaningfully speeding up deploys and autoscaling events), and a scratch-based image has essentially no attack surface for OS-level vulnerabilities, since there's no OS present at all to have vulnerabilities in. This is a significant, practical operational advantage Go has over languages that require shipping a full interpreter/runtime and its dependencies inside every container image.

Before slog (added in Go 1.21), structured logging in Go meant reaching for a third-party library like zap or logrus. Now it's built directly into the standard library.

Unstructured vs. structured logging

// Unstructured: hard to query reliably at scale
log.Printf("user %d logged in via %s", userID, method)
// "user 42 logged in via oauth" — good luck filtering by user_id in a log aggregator
// Structured: user_id and method are real, queryable fields
slog.Info("user logged in", "user_id", 42, "method", "oauth")

JSON output for production log aggregation

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)

slog.Info("user logged in", "user_id", 42, "method", "oauth")
{"time":"2024-01-15T10:30:00Z","level":"INFO","msg":"user logged in","user_id":42,"method":"oauth"}

Most log aggregation platforms (Datadog, Elasticsearch, CloudWatch) can index this directly, letting you filter on user_id=42 reliably, something regex-parsing a free-form message string can never do robustly.

Leveled logging

Four severity levels cover most needs:

slog.Debug("cache miss", "key", cacheKey)
slog.Info("request handled", "path", r.URL.Path, "status", 200)
slog.Warn("retrying request", "attempt", 3)
slog.Error("database connection failed", "error", err)

Attaching persistent context to a logger

slog.With avoids repeating the same fields on every call:

requestLogger := slog.With("request_id", reqID, "user_id", userID)
requestLogger.Info("processing request")
requestLogger.Info("request completed", "duration_ms", 42)

slog.With returns a new logger carrying those fields on every subsequent call, so you don't have to repeat request_id/user_id manually on every log line within a single request's handling.

Why this matters in production

Structured logs are what make real observability tooling (dashboards, alerts, log-based metrics) actually work reliably. A log aggregator can build a dashboard of "errors by user_id" trivially from structured JSON fields. It essentially can't do that robustly from free-text log messages with inconsistent formatting across a codebase.

Related Resources

Most Go anti-patterns come from applying habits or patterns from other languages that don't fit Go's actual design philosophy.

Premature interface abstraction

// Anti-pattern: an interface with exactly one implementation, defined "just in case"
type UserRepository interface {
    GetUser(id int) (*User, error)
}
type postgresUserRepository struct{ db *sql.DB }
func (r *postgresUserRepository) GetUser(id int) (*User, error) { ... }

If there's genuinely only one implementation and no test double need yet, this interface adds indirection with no real payoff. Go's own guidance, "accept interfaces, return structs," favors defining an interface at the consumer side. Do that when a second implementation or a test double actually becomes necessary, not speculatively ahead of time.

Silently ignoring errors

data, _ := os.ReadFile("config.json")   // if this fails, data is nil, and you'll get a
                                          // confusing downstream error instead of a clear one
json.Unmarshal(data, &cfg)
data, err := os.ReadFile("config.json")
if err != nil {
    return fmt.Errorf("reading config: %w", err)
}

Overusing any where a concrete type or generic would do

func Process(items []any) { ... }   // loses all type safety, forces assertions everywhere
func Process[T any](items []T) { ... }   // type-safe, same flexibility

Unsynchronized global mutable state

var globalCache = map[string]string{}  // no mutex — a data race waiting to happen
                                         // the moment two goroutines touch it concurrently

Sprawling "util" or "common" packages

package util
func FormatDate(...) {}
func ValidateEmail(...) {}
func ParseConfig(...) {}
func SendEmail(...) {}
// dozens of unrelated functions dumped into one package with no clear boundary

A package like this accumulates unrelated responsibilities over time, makes it hard to reason about what actually depends on what, and often becomes a dependency magnet that couples unrelated parts of a codebase together unnecessarily.

The underlying theme

Nearly every one of these traces back to designing for hypothetical future flexibility. Go's simplicity-first philosophy doesn't ask for that, and it costs the clarity and directness the language is actually optimized for.

Related Resources

Go has no single blessed configuration approach the way some frameworks in other languages do, but a few patterns are common and idiomatic.

A typical Config struct, populated from environment variables

type Config struct {
    Port        int
    DatabaseURL string
    LogLevel    string
}

func LoadConfig() (*Config, error) {
    port, err := strconv.Atoi(getEnv("PORT", "8080"))
    if err != nil {
        return nil, fmt.Errorf("invalid PORT: %w", err)
    }
    cfg := &Config{
        Port:        port,
        DatabaseURL: os.Getenv("DATABASE_URL"),
        LogLevel:    getEnv("LOG_LEVEL", "info"),
    }
    if cfg.DatabaseURL == "" {
        return nil, fmt.Errorf("DATABASE_URL is required")
    }
    return cfg, nil
}

func getEnv(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}

Failing fast at startup

func main() {
    cfg, err := LoadConfig()
    if err != nil {
        log.Fatalf("invalid configuration: %v", err)  // fail immediately, not mid-request
    }
    // ... start server using cfg ...
}

Discovering a missing required setting the first time a specific request path is hit, minutes or hours after deployment, is a far worse operational experience than the process refusing to start at all with a clear error message.

Command-line flags for local development

port := flag.Int("port", 8080, "server port")
flag.Parse()

Flags are common for quick local overrides during development, though many production deployments rely primarily on environment variables since that's what container orchestrators (Kubernetes, ECS) naturally inject.

Structured config files for larger settings

# config.yaml
database:
  host: localhost
  port: 5432
  pool_size: 10
logging:
  level: info
  format: json

For services with many structured settings, a YAML/TOML file parsed into a Config struct (often via viper or plain encoding/yaml) can be clearer than dozens of flat environment variables, sometimes combined with environment variables for secrets/environment-specific overrides on top of file-based defaults.

The general principle

Whatever combination of sources you use, validate the fully assembled configuration once, at startup, and fail immediately and loudly if anything required is missing or invalid.

Go and Rust get compared constantly since both emerged as "better than C/C++ for this specific niche," but they optimize for genuinely different things.

The core tradeoff

GoRust
Memory managementGarbage collectedOwnership/borrowing, no GC
Learning curveShallow — productive within daysSteep — borrow checker takes real time to internalize
Compile timesFastOften noticeably slower, especially for large projects
Runtime performanceVery good, GC pauses are brief but nonzeroTypically faster, zero GC overhead
Concurrency modelGoroutines + channels, simple to reason aboutFearless concurrency via ownership, but more upfront complexity
Common use casesNetwork services, CLIs, infrastructure tooling, cloud platformsSystems programming, embedded, performance-critical libraries, WASM

A concrete example of the difference in feel

// Go: simple, GC handles the memory
func processData(data []byte) []byte {
    result := make([]byte, len(data))
    copy(result, data)
    return result
}
// Rust: ownership rules enforced at compile time, no GC needed
fn process_data(data: &[u8]) -> Vec<u8> {
    data.to_vec()
}

The Rust version has zero runtime GC overhead, but understanding exactly why &[u8] borrows without taking ownership, and how lifetimes interact with function signatures, takes real time to learn well.

Why cloud-native infrastructure tends toward Go

Kubernetes, Docker, Terraform, and most of the cloud-native tooling ecosystem are written in Go. Go's fast compile times, simple concurrency model, and easy cross-compilation were a strong fit for building networked infrastructure tools quickly. A large pool of developers can also contribute productively without a long ramp-up.

Why Rust wins elsewhere

Rust's zero-cost abstractions and compile-time memory safety guarantees make it the stronger choice in a few situations: when GC pauses are unacceptable (real-time systems, some game engines), when you need C-level control over memory layout, or when a garbage collector's runtime isn't available or desirable at all, like embedded systems or WebAssembly modules with tight resource budgets.

The honest framing for an interview

Neither language is strictly "better." Go trades some raw performance and memory-usage control for dramatically faster developer ramp-up and simpler concurrency. Rust trades a steeper learning curve for maximum performance and compile-time-guaranteed memory safety with zero GC overhead.

Related Resources