Same Container Image, Different Application

The image passed staging. Production starts the same digest with different environment variables.
The browser still calls the staging API.
There is no contradiction. The server received its configuration at startup. The browser bundle received its configuration earlier, during the build. "Same image" was true. "Same application, configured at runtime" was only partly true.
I like building once and promoting an immutable artifact. It removes a whole class of uncertainty about what was tested. But that promise deserves a more careful definition than "we use Docker now."
Separate the configuration clocks
There are at least three moments when configuration can become fixed:
- build time, when the artifact is produced;
- process startup, when the runtime initializes;
- request time, when a value is consulted again.
A public frontend environment variable may be substituted into JavaScript during the build. Changing the container environment does not rewrite the compiled bytes. Next.js documents this behavior for NEXT_PUBLIC_ values; Vite's VITE_ values have a similar public-build role.
For a browser API address, I would first consider same-origin paths such as /api. The proxy selects the environment-specific backend without putting that address into the bundle.
If the browser truly needs runtime settings, publish a small, explicitly public configuration resource or inject them into the initial response. Validate it and decide its cache behavior. No secret becomes safe because the file is named runtime-config.json.
This also creates compatibility work: an old bundle may read new settings. Keep the public configuration schema additive, and record which artifact and configuration revision were deployed together.
Environment variables do not form a typed contract
The process can start with ORDER_TIMEOUT_MS=ten. It can also start with a missing variable that quietly activates a development default.
I would rather reject an invalid production configuration before the instance receives traffic:
import { z } from 'zod'; const RuntimeConfig = z.object({ DATABASE_URL: z.string().url(), ORDER_TIMEOUT_MS: z.coerce.number().int().min(100).max(30_000), ENABLE_EXPORTS: z.enum(['true', 'false']), }); const config = RuntimeConfig.parse(process.env); const exportsEnabled = config.ENABLE_EXPORTS === 'true';
The values are illustrative, not recommended timeouts. Notice the boolean handling: the string 'false' is truthy in JavaScript. A generic truthiness conversion is not a configuration parser.
Errors should identify the invalid setting without dumping every environment value into logs. Validation is useful; printing a database password beside the validation failure is not.
Secret mounts solve one part of the problem
Builds sometimes need credentials to download private dependencies. Passing them through Docker build arguments or environment instructions can leave them in image metadata or layers. Deleting a file in a later layer does not remove it from the earlier layer.
BuildKit secret mounts expose a credential to one build step without intentionally baking it into the image:
RUN \ npm ci
That does not prevent the invoked tool from copying the credential into output or printing it. The mount is a delivery mechanism, not a guarantee about everything executed in that step.
Runtime secrets have a different lifecycle. An environment-injected database password usually stays unchanged in the process until it restarts. A mounted secret file may update while the application still holds the old value in memory. A secrets service can rotate credentials while existing database connections continue to work and new connections fail.
Rotation therefore includes application behavior: refresh or restart, connection-pool replacement, overlap between old and new credentials, and rollback. A secret manager can store the next password. It cannot decide when your client library rereads it.
Pin the artifact you mean to deploy
An image tag is a name. A digest identifies particular content.
If checkout:production is overwritten while deployment is in progress, instances pulling at different moments can receive different bytes under the same tag. It is possible to build safeguards around tags, but deploying a recorded digest gives the release a less ambiguous identity.
Record the source revision, image digest, configuration revision, and migration state. That is enough to explain considerably more than "version 18 is running."
Architecture matters too. A locally built image for one CPU architecture is not automatically a usable artifact on another. Multi-platform builds help, but native dependencies still need testing in the runtime actually deployed.
A container does not make local files durable
A generated invoice written into the container filesystem belongs to that instance. The next request may land elsewhere, and the next deployment may remove it entirely.
Use the local filesystem for temporary work with a known lifetime. Put durable exports, uploads, and receipts in deliberately managed storage. Return an object identifier rather than a path inside the process that happened to create the file.
The same distinction applies to caches. A local cache is useful if losing it is safe and other instances can tolerate different entries. A shared product truth should not accidentally live in whichever container first computed it.
Read-only root filesystems and non-root execution can make accidental persistence and unnecessary privileges harder. They also expose implicit assumptions in libraries that want to write temporary files. Test the constrained runtime, not just the permissive development container.
Resource limits change behavior before a process dies
CPU throttling can stretch an ordinary request past its timeout without high host CPU usage. Memory limits can kill a process while the host still has plenty of RAM. Event-loop delay may explain latency that database timing does not.
I would set concurrency and heap behavior with the container's actual limits in mind. More parallel promises are not always more throughput.
The database pool deserves particular attention. A pool of twenty connections seems modest until twelve replicas each create one. During a rolling deployment, old and new replicas overlap, and the peak connection count can be larger than the steady-state count.
We will examine that budget in the scaling article. For now, the useful habit is to treat per-process settings as quantities that multiply when the scheduler adds processes.
Do not let every replica migrate the database
Running migrations in the application's startup command is convenient for one instance. Start ten instances and you have ten potential migration runners, possibly while the old application still serves traffic.
Use a controlled release step with explicit migration ownership, lock behavior, retry policy, and compatibility gates. Database tools may provide advisory locking; that still does not make a destructive change compatible with the old deployment.
The expand/contract approach belongs to the release sequence, not merely to the SQL file.
Startup and readiness also need separate meanings. The process may be alive while warming caches or preparing required resources. Conversely, restarting every replica because one shared dependency is briefly unavailable can add more load to that dependency.
Health checks should support the failure response we want, rather than giving every unpleasant condition the same instruction: restart.
Test shutdown as carefully as startup
A container usually gets a termination signal and a finite grace period. If the process ignores the signal or an intermediate shell fails to forward it correctly, deployment ends by force-killing work.
Use a process arrangement that forwards signals. Stop accepting new work, allow in-flight requests to finish within a bound, and let queue workers acknowledge only completed jobs. Design what happens after the deadline too; the platform will not wait indefinitely because an export is nearly done.
An immutable image is useful precisely because it narrows uncertainty. It does not remove configuration, storage, or process lifecycle from the design. I would keep the artifact fixed and make those moving parts explicit enough to reproduce.
Next, a practical AWS deployment puts these decisions into a concrete infrastructure layout, without treating every available service as a requirement.
Which setting in your application changes at a different time than people assume? I'd be interested in the small configuration detail that made an otherwise sound release behave differently.
About the author
Maksym Kuzmitskyi is a Senior Software Developer focused on React and TypeScript. His work includes enterprise applications, shared frontend components, Node.js integration, testing and accessibility. Alongside frontend work, he designs AI-assisted delivery workflows and has developed personal review tooling.
More than a blog post
I share frontend news and the reasoning behind it throughout the day. Pick the language that feels natural to you.