System Journal
#kubernetes#vault#backend#securityintermediate

Rotating Database Credentials Without Restarting Pods

3 min read

All examples here are generalized and use toy services. They do not disclose any employer, client, or proprietary implementation detail.

Context

A backend service connects to a database using a username and password. Security policy requires those credentials to rotate on a schedule. The naive approach — bake the password into an environment variable — means every rotation requires a redeploy or pod restart to pick up the new value.

Problem

Restarting on every rotation is expensive: it drops in-flight connections, causes brief unavailability, and couples a security operation (rotation) to a delivery operation (deploy). We want the running process to pick up new credentials without restarting.

Constraints

  • No restarts on rotation.
  • The app must never use a stale credential after the old one is revoked.
  • Must degrade safely if the new credential is briefly unreadable.
  • No secrets in environment variables or images.

Options considered

  1. Env vars + restart on rotation. Simple, but violates the no-restart goal.
  2. Fetch the secret from a vault API on every DB connect. No restart, but adds a network hop and a hard dependency on the vault being up at connect time.
  3. A sidecar agent writes the current secret to a shared file; the app watches that file and reloads on change. No restart, no per-connect network hop, and the app reads from local disk. This is the approach.

Final approach

A secrets agent (running as a sidecar) authenticates to the vault, renders the current credential to a file on a shared volume, and keeps that file up to date as the credential rotates. The application watches the file and, on change, swaps the credential used by its connection pool — draining old connections gracefully.

// Pseudocode — watch the rendered secret file and swap the pool's credentials.
import { watch, readFile } from 'node:fs/promises';

async function watchCredentials(path: string, onChange: (secret: string) => void) {
  // Read once at startup so we never run with no credential.
  onChange(await readFile(path, 'utf8'));

  for await (const _event of watch(path)) {
    try {
      const next = await readFile(path, 'utf8');
      onChange(next); // swap into the pool; drain old connections.
    } catch (err) {
      // File mid-write or briefly missing: keep the current credential, retry.
      // Never tear down the pool on a transient read error.
    }
  }
}

Implementation notes

  • Read at startup, then watch. Never start the watcher without an initial read, or you risk a window with no usable credential.
  • Swap, don’t restart the pool. New connections use the new credential; existing connections drain naturally. The old credential stays valid for an overlap window.
  • Atomic writes matter. The agent should write to a temp file and rename, so the app never reads a half-written secret. File watching fires on partial writes too.

Failure modes

  • Read fires mid-write → guard with try/catch, keep the current credential.
  • Old credential revoked before overlap ends → in-flight connections fail; size the overlap window to exceed your longest healthy connection lifetime.
  • Watcher dies silently → add a heartbeat/last-reload metric; alert if the file’s mtime advances but the app’s last-reload timestamp doesn’t.

Testing / validation

  • Unit: simulate a file change, assert the pool receives the new credential.
  • Integration: rotate the credential in a test vault, assert zero failed queries across the rotation window.
  • Chaos: delete the file mid-run, assert the app keeps serving on the cached credential.

What I learned

The hard part isn’t watching a file — it’s the overlap window. Rotation is only safe if the old credential outlives every connection opened under it. Treat the window as a first-class parameter, not an afterthought.

Related Articles