DEV Community

Dmitry Tilyupo
Dmitry Tilyupo

Posted on

Stop Redeploying Just to Change a Config Value

Last Friday, our API got hammered during a flash sale. Rate limit was set to 100 requests/second. We needed 50. Our deploy pipeline? 12 minutes.

That's when I realized: environment variables are static configuration. They change when you redeploy. But some values need to change now.

The Problem I Kept Hitting

Most Node.js apps start like this:

const config = {
  rateLimit: parseInt(process.env.RATE_LIMIT || "100"),
  featureNewCheckout: process.env.FEATURE_NEW_CHECKOUT === "true",
  cacheMaxAge: parseInt(process.env.CACHE_MAX_AGE || "3600"),
};
Enter fullscreen mode Exit fullscreen mode

Fine for database URLs and API keys. But for these? Pain:

  • Rate limits during traffic spikes
  • Feature flags for gradual rollouts
  • Timeouts when downstream services are slow
  • Kill switches for broken features

Every change = PR → review → merge → CI → deploy → pray.

What Dynamic Configuration Actually Is

Three properties make config "dynamic":

  1. Changes propagate without restarts. Update a value, running instances get it in seconds.
  2. Values are evaluated at read time. Not cached forever at startup.
  3. History is preserved. Who changed what, when. Instant rollback if needed.

Three Ways to Implement It

1. Polling (Simplest)

Fetch config from an external store on a timer:

let config: Config = JSON.parse(readFileSync("./config.json", "utf-8"));

setInterval(async () => {
  try {
    const response = await fetch("https://config-api.internal/v1/config");
    config = await response.json();
  } catch (error) {
    console.error("Failed to refresh config:", error);
    // Keep using last known good config
  }
}, 30_000);
Enter fullscreen mode Exit fullscreen mode

Pros: Works with anything (Redis, DB, HTTP API).

Cons: Updates delayed by polling interval. Wasteful if config rarely changes.

2. Webhooks (Push-based)

Config server pushes updates when they happen:

app.post("/config-webhook", express.json(), (req, res) => {
  const { secret, payload } = req.body;

  if (secret !== process.env.WEBHOOK_SECRET) {
    return res.status(401).json({ error: "Invalid secret" });
  }

  config = payload;
  res.json({ ok: true });
});
Enter fullscreen mode Exit fullscreen mode

Pros: Faster updates.

Cons: Need to expose endpoint, handle auth, ensure all instances get the update.

3. Server-Sent Events (Best of Both)

Persistent connection, server streams updates:

import { EventSource } from "eventsource";

const source = new EventSource("https://config-api.internal/v1/stream", {
  headers: { Authorization: `Bearer ${process.env.CONFIG_API_KEY}` },
});

source.addEventListener("config_change", (event) => {
  const change = JSON.parse(event.data);
  config = { ...config, [change.name]: change.value };
});
Enter fullscreen mode Exit fullscreen mode

Pros: Real-time updates (<100ms). Auto-reconnection. No inbound endpoints.

Cons: Needs infrastructure that supports long-lived connections.

Beyond Key-Value: What Real Apps Need

Simple get(key) isn't enough. You also need:

  • Type safety — TypeScript should know rateLimit is a number
  • Context-aware values — Different rate limits for premium vs free users
  • Defaults — Keep running if config server is down
  • Subscriptions — React when specific values change

Here's what this looks like with Replane (open-source, SSE-based):

import { Replane } from "@replanejs/sdk";

interface Configs {
  "api-rate-limit": number;
  "feature-new-checkout": boolean;
  "cache-settings": { maxAge: number; staleWhileRevalidate: number };
}

const replane = new Replane<Configs>({
  defaults: {
    "api-rate-limit": 100,
    "feature-new-checkout": false,
    "cache-settings": { maxAge: 3600, staleWhileRevalidate: 60 },
  },
});

await replane.connect({
  sdkKey: process.env.REPLANE_SDK_KEY!,
  baseUrl: "https://cloud.replane.dev",
});

// Type-safe
const rateLimit = replane.get("api-rate-limit");

// Context-aware
const userRateLimit = replane.get("api-rate-limit", {
  context: { userId: user.id, plan: user.subscription },
});

// React to changes
replane.subscribe("cache-settings", (config) => {
  cacheManager.configure(config.value);
});
Enter fullscreen mode Exit fullscreen mode

When to Use Dynamic Config

Good candidates:

Use Case Example Why Dynamic
Feature flags new-checkout-enabled Roll out to 1% → 10% → 100%
Rate limits api-rate-limit Respond to traffic spikes
Timeouts downstream-timeout-ms Adjust for slow services
Kill switches payments-enabled Disable broken features instantly

Keep as env vars:

  • Database connection strings
  • API keys and secrets
  • Service URLs
  • Log destinations

These don't change while running (and shouldn't).

Common Mistakes

  1. Putting secrets in dynamic config — Use a secrets manager instead.
  2. Using it for everything — DB URLs don't need instant updates.
  3. No defaults — App should start even if config server is down.
  4. Fetching on every request — Cache in memory, update via SSE/polling.
  5. No validation — A typo shouldn't crash production.

Getting Started

  1. Identify candidates — What do you wish you could change without deploying?
  2. Set up a backend — Start simple (JSON file, Redis) or use something like Replane.
  3. Add defaults — Always have fallbacks.
  4. Migrate incrementally — Start with one value, expand from there.
  5. Monitor config changes — Know when changes happen.

If you're building something similar or have questions, drop a comment. Been running this in production for a while now and happy to share what worked (and what didn't).

Top comments (0)