How to Use ConfigMaps and Secrets with Go Apps in Kubernetes

Inject ConfigMaps and Secrets into Go apps via environment variables or mounted files in Kubernetes deployments.

The configuration problem

Your Go application works perfectly on your laptop. It connects to a local database, logs to stdout, and routes traffic on port 8080. You push the binary to a container registry and deploy it to Kubernetes. The container starts, immediately panics, and crashes. The database password is hardcoded. The log level is baked into the binary. The port conflicts with a sidecar proxy. Hardcoding configuration breaks the separation between your application logic and its deployment environment. You need a reliable way to hand configuration to a container without recompiling.

Kubernetes solves this with ConfigMaps and Secrets. Both are cluster-scoped resources that store key-value pairs. ConfigMaps hold non-sensitive text like feature flags, log levels, and API endpoints. Secrets hold sensitive text like database passwords, TLS certificates, and API keys. Kubernetes injects them into your running container through two mechanisms: environment variables or mounted files. Your Go code reads them using the standard library. The binary never changes. The environment does.

How Kubernetes hands you data

Kubernetes treats configuration as a separate layer that slides into place before your process starts. Think of your Go binary as a recipe. The recipe defines the steps. ConfigMaps and Secrets are the spice rack and the locked safe. The chef doesn't bake the spices into the cookbook. The kitchen staff places them on the counter. When the chef starts cooking, the ingredients are already there.

Under the hood, Kubernetes uses Linux namespaces and virtual filesystems. When you request an environment variable, the kubelet writes the key-value pair into the container's /proc/1/environ file before executing your binary. When you request a mounted file, the kubelet creates a tmpfs or hostPath volume, writes the data into it, and bind-mounts it to a directory inside the container. Your Go process sees both as standard OS primitives. The os package abstracts the difference. You call os.Getenv for variables. You call os.ReadFile for files. The runtime handles the rest.

Reading environment variables

Environment variables are the fastest way to pass small configuration values. They require no filesystem I/O. They are available immediately when main starts. Here is a minimal ConfigMap and Deployment that injects a log level:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  LOG_LEVEL: "info"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: go-app
spec:
  template:
    spec:
      containers:
      - name: go-app
        image: my-go-app:latest
        env:
        - name: LOG_LEVEL
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: LOG_LEVEL

The deployment references the ConfigMap using configMapKeyRef. Kubernetes resolves the reference, injects the value, and starts the container. Your Go code reads it like any other environment variable:

package main

import (
	"fmt"
	"os"
)

// main starts the application and reads configuration from the environment.
func main() {
	// os.Getenv returns an empty string if the key is missing.
	// Always check for empty values when the config is required.
	logLevel := os.Getenv("LOG_LEVEL")
	if logLevel == "" {
		logLevel = "warn"
	}

	// Print the resolved configuration to verify injection.
	fmt.Printf("Starting with log level: %s\n", logLevel)
}

The os.Getenv function makes a single syscall to read the process environment. It returns a string. If the key does not exist, it returns an empty string. It never panics. It never returns an error. You must validate the value yourself. The Go community accepts this design because it keeps the standard library small and forces developers to handle missing configuration explicitly. Silently falling back to defaults hides deployment mistakes.

Environment variables have a hard limit on Linux. The kernel caps the total size of all environment variables at roughly 128 kilobytes. Passing large JSON blobs or certificate chains through env will fail with exec format error or argument list too long. Keep environment variables for short strings, flags, and single-line values.

Environment variables are static. Changing a ConfigMap after the pod is running does not update the process environment. You must restart the pod to pick up new values. Treat environment variables as immutable for the lifetime of the container.

Reading mounted files

Mounted files solve two problems that environment variables cannot. They support larger payloads. They support hot-reloading without restarting the process. Kubernetes writes each key in a ConfigMap or Secret as a separate file inside the mount directory. The filename matches the key. The file contents match the value.

Here is a Deployment that mounts both a ConfigMap and a Secret:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: go-app
spec:
  template:
    spec:
      containers:
      - name: go-app
        image: my-go-app:latest
        volumeMounts:
        - name: config-volume
          mountPath: /etc/config
        - name: secret-volume
          mountPath: /etc/secrets
      volumes:
      - name: config-volume
        configMap:
          name: app-config
      - name: secret-volume
        secret:
          secretName: app-secret

The volumeMounts section maps the Kubernetes volumes to absolute paths inside the container. The volumes section declares where the data comes from. Kubernetes creates the directories, writes the files, and sets restrictive permissions on the secret volume. Your Go code reads them like regular files:

package main

import (
	"fmt"
	"os"
)

// main reads configuration from mounted files and handles missing data.
func main() {
	// Read the log level from the mounted config directory.
	// os.ReadFile returns bytes, so convert to string for display.
	data, err := os.ReadFile("/etc/config/LOG_LEVEL")
	if err != nil {
		fmt.Printf("Missing config file: %v\n", err)
		return
	}
	logLevel := string(data)

	// Read the database password from the mounted secret directory.
	// Never discard errors when reading secrets. Fail fast.
	passwordBytes, err := os.ReadFile("/etc/secrets/DB_PASSWORD")
	if err != nil {
		fmt.Printf("Missing secret file: %v\n", err)
		return
	}
	password := string(passwordBytes)

	fmt.Printf("Config loaded: level=%s, secret_len=%d\n", logLevel, len(password))
}

The os.ReadFile function opens the file, reads its entire contents into a byte slice, and closes the file. It returns an error if the path does not exist or if permissions block the read. The Go convention is to check if err != nil immediately. The boilerplate is verbose by design. It makes the unhappy path visible. Discarding the error with _ hides deployment failures and leaves your application running with empty configuration.

Mounted files update automatically when the underlying ConfigMap or Secret changes. Kubernetes watches the API server and updates the volume contents. Your Go process sees the new data on the next read. You can build a hot-reloading configuration system by reading the files in a background goroutine or using fsnotify to watch for changes. The filesystem is the synchronization primitive.

Secrets mounted as files have restricted permissions. Kubernetes sets the directory to 0755 and the files to 0644 by default. You can tighten this with defaultMode in the volume definition. Environment variables are visible to any process in the container through /proc/1/environ. Mounted files are safer for sensitive data.

Mounted files require filesystem I/O. Reading a dozen small files adds latency to startup. Batch your reads or cache the results in memory. Do not read configuration files on every HTTP request.

Pitfalls and runtime surprises

Configuration injection looks simple until the cluster behaves unexpectedly. Missing keys cause silent failures. Type mismatches cause compile errors. Hot-reloading introduces race conditions. Understanding the failure modes saves hours of debugging.

The compiler rejects type mismatches immediately. If you pass a byte slice where a string is expected, you get cannot use data (variable of type []byte) as string value in argument. Convert explicitly with string(data). If you forget to import os, you get undefined: os. If you import it but never use it, you get imported and not used. The Go compiler enforces strict hygiene. Fix the imports and move on.

Runtime panics happen when you assume configuration exists. os.Getenv returns an empty string for missing keys. os.ReadFile returns an error. If you skip the error check and call string(nil), the program prints an empty string and continues. If you index into a missing slice, you get panic: runtime error: index out of range. Validate required configuration at startup. Fail the process if critical values are absent. A crashing pod is better than a silently misconfigured service.

Hot-reloading introduces concurrency hazards. If one goroutine reads a file while Kubernetes updates it, you might read a partial file. Kubernetes writes to a temporary file and atomically renames it. The rename is atomic on POSIX systems. Reading the file after the rename guarantees a complete payload. Do not read files during a write. Use a mutex or a double-buffer pattern if multiple goroutines access the configuration simultaneously.

Environment variables inherit from the parent process. If your Dockerfile sets ENV LOG_LEVEL=debug, Kubernetes cannot override it with a ConfigMap reference. The container runtime merges environment variables, and explicit ENV instructions win. Clear your base image environment variables or use env overrides in the Deployment spec.

The worst configuration bug is the one that never logs. Print the resolved configuration at startup. Mask sensitive values. Record the source of each value. When a pod behaves strangely, you need to know whether the cluster handed you the right data.

When to pick which injection method

Use environment variables when you need short, immutable values like feature flags, log levels, or single-line endpoints. Use mounted files when you need larger payloads, multi-line certificates, or JSON configuration blobs. Use mounted files when you require hot-reloading without restarting the container. Use environment variables when you want zero filesystem I/O during startup. Use a configuration library like koanf or viper when you need to merge multiple sources, validate schemas, or support fallback defaults. Use the Kubernetes API directly with client-go when your application needs to watch configuration changes in real time or manage its own secrets. Use plain hardcoded defaults only for development environments where cluster access is unavailable.

Configuration is plumbing. Run it through every long-lived call site.

Where to go next