DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Day 23: Change config without a restart, keep secrets out of git

Two days ago OrderHub got a central Config Server. Yesterday the services learned to load-balance their own calls. But there was still a gap I hadn't closed: change a value in the config repo, and the running services have no idea. They bind their configuration once, at startup. To pick up a new value you'd redeploy every instance — an absurd price for flipping a feature flag or nudging a page size.

Day 23 fixes that, and then tackles the thing nobody should get wrong: keeping secrets out of the repo entirely.

Live refresh: two beans, one config change

The trick is Spring Cloud's refresh scope, riding on Actuator. First you expose the endpoint:

management:
  endpoints:
    web:
      exposure:
        include: health, circuitbreakers, refresh, env
Enter fullscreen mode Exit fullscreen mode

A POST /actuator/refresh now tells the app to reload. Under the hood the ContextRefresher does two things: it rebuilds the Environment (re-fetching config from the server), then disposes every bean marked @RefreshScope. Those beans aren't eager singletons anymore — the real target is re-created lazily on the next access, reading the fresh Environment.

@Component
@RefreshScope
public class RefreshableOrderConfig {
  public RefreshableOrderConfig(
      @Value("${app.orders.greeting:OrderHub}") String greeting,
      @Value("${app.orders.express-checkout-enabled:false}") boolean flag) {
    // re-read on every refresh
  }
}
Enter fullscreen mode Exit fullscreen mode

To prove a refresh genuinely rebuilds the bean (rather than just re-reading a value that happened to change), I gave it a fingerprint — a static counter incremented in the constructor. A refresh re-creates the bean, so the count ticks up even when nothing changed. Next to it I put a StaticOrderConfig with the same @Value fields but no @RefreshScope: a plain singleton, frozen at startup values until the process restarts. A /api/config endpoint returns both side by side, so you can watch one update and the other stay stale.

That contrast is also what the test asserts, with no live server:

long before = refreshable.getInstanceSeq();
contextRefresher.refresh();                          // == POST /actuator/refresh
assertThat(refreshable.getInstanceSeq()).isGreaterThan(before);      // re-created
assertThat(staticConfig.getInstanceSeq()).isEqualTo(staticBefore);   // frozen
Enter fullscreen mode Exit fullscreen mode

@RefreshScope is opt-in on purpose: you annotate exactly the handful of beans that must flip live, and leave everything else safely frozen until a real deploy.

One instance at a time is fine for a demo. In production you run many, and hitting each by hand is fragile. That's what Spring Cloud Bus is for: link every instance over RabbitMQ or Kafka, and a single POST /actuator/busrefresh publishes a refresh event to the whole fleet. Wire a config-repo webhook to it and a git push becomes a cluster-wide refresh. I implemented single-service refresh here; the Bus is the drop-in scale-out.

Secrets: the repo is forever

Config lives in git, and git never forgets. A password or API key committed to the config repo — even in a private repo, even deleted in a later commit — is leaked. It's in the history, it's scraped by bots the instant it hits a remote, and "rotating" it means a commit. The rule is absolute: configuration goes in the repo, secrets do not.

So I pulled the shipping API key out and left only a reference behind:

app:
  orders:
    integrations:
      shipping-api-key: ${SHIPPING_API_KEY:local-dev-placeholder-not-a-real-secret}
Enter fullscreen mode Exit fullscreen mode

The committed value is a placeholder. At runtime Spring resolves it from the SHIPPING_API_KEY environment variable, injected by the platform and never committed. The safe default keeps local dev and tests booting without the real key; in production you drop it so a missing var fails loudly instead of silently running with a fake. The application code just reads a property — it has no idea the value came from the environment. And it never logs or returns the raw value; a SecretMasker turns it into a fingerprint (sk_l…N1) for diagnostics.

Two heavier options are worth knowing. Config Server encryption lets you commit {cipher}AQB... values that the server decrypts with an encrypt.key held outside the repo — only ciphertext is committed. And Vault / AWS Secrets Manager is the real production answer: access policies, an audit trail, automatic rotation, even short-lived dynamic credentials. Spring Cloud Vault plugs in as another PropertySource, so ${...} resolution in your code is unchanged. That indirection is the whole point — you can swap the backend from env var to {cipher} to Vault without touching a line of application code.

The frontend half

On the frontend, "environment variable" means something completely different. A Vite VITE_* var is inlined into the bundle at build time — it ships to every browser and anyone can read it. So the frontend never holds a secret: privileged calls are proxied through the backend that holds the real credential, and runtime feature flags come from a small backend endpoint (backed by that same refreshable config), so product can flip a feature live with no frontend rebuild.

Config you can change freely, secrets you cannot leak, and a client that stays dumb about both.

Live demo: https://dev48v.infy.uk/orderhub.php
Code: https://github.com/dev48v/order-hub-from-zero

Next up, Day 24: inter-service authentication with service tokens.

Top comments (0)