How I Serve Three Environments From One Domain Name I Could Not Change
On this page
- Introduction
- A. What a socket actually is
- B. Why the name could not simply be different
- C. What the other separations already handle
- D. What is at stake in getting it wrong
- E. What the proxy has to get right
- F. Four obstacles in the way
- Method
- A. Separate at the socket, not at the name
- B. Choosing an environment is choosing an address
- C. The configuration that version control could edit
- D. One template instead of five near-identical files
- E. The substitution that passes the syntax check and is wrong
- F. Failing before touching anything
- G. Marking the environments a name cannot distinguish
- Results and Discussion
- A. The check that does not check what you assume
- B. The header nobody would think to check
- C. What one proxy for three environments actually costs
- D. Where this does not apply
- Conclusion
- Terms
One domain name. Three environments. One machine, and one web server in front of all of it.
That arrangement sounds like it should be impossible, or at least ugly. It turned out to be neither — though getting there meant deciding exactly how far version control may reach into live configuration.
Introduction
When a browser asks for a page, something has to decide which program answers. On a machine running a single application that decision is trivial: there is only one candidate. On a machine running three copies of the same application — development, staging, and production — it becomes the entire problem.
The component that makes that decision is a reverse proxy: a web server that sits in front of everything, accepts requests from the outside, and forwards each one to whichever internal service should handle it. Nothing reaches the applications without passing through it first.
A. What a socket actually is
Because the answer in this article turns on a word most articles use without explaining, it is worth a paragraph.
A machine does not have one network address; it has several. There is the loopback address, which only that machine can reach — nothing outside it can route there at all. There is an address on the local network, which other machines nearby can reach. There may be more: an address on a private network interface, an address given by a VPN.
On top of any of those addresses, a program can claim a port — a number that separates one service from another on the same address. Web traffic conventionally uses port 443 for encrypted connections.
The pair of them together — one address, one port — is a socket. It is the specific doorway a server listens at. And here is the property that matters: two different programs can both listen on port 443 at the same time, as long as they claim it on different addresses. The operating system considers those separate doorways, because they are.
That is the whole mechanism this article rests on. If environments cannot be told apart by name, they can be told apart by which doorway the request arrived at.
There is a further property I only came to appreciate later. The loopback address has no route from outside the machine — not because a rule forbids it, but because the network simply provides no path there. A development environment listening on it is unreachable from outside, and that holds even if every other piece of configuration is wrong. The separation is enforced by the network rather than by discipline.
B. Why the name could not simply be different
The obvious approach is three hostnames: dev.example, staging.example, and the real one. Most projects do exactly that, and for most projects it is the right answer.
It was not available to me, and the reason is worth stating because it is a constraint many internal systems share.
The system serves a public institution, and its domain is fixed in places I do not control. It appears in certificates that have already been issued — a certificate is a signed statement binding a public key to a specific name, so a certificate for one name proves nothing about another. It appears in configuration held by other organisations that connect to the system. And it appears in documents that have already been signed, where the name is part of what was attested.
Testing against a different hostname would mean the environment doing the rehearsal is not exercising the thing production actually uses. The name is not incidental here; it is part of what is being tested.
So the constraint was fixed: the same hostname must reach three different environments, and something other than the name has to decide which.
C. What the other separations already handle
The proxy is not the only thing keeping these environments apart, and it helps to know what it is not responsible for.
Each environment has its own container network with its own address range, so a service in one cannot reach a database in another — the addresses simply do not resolve across the boundary. Each keeps its data in its own directory outside the project folder, so copying or deleting a project folder cannot touch a database. And each declares its own project identity to the container runtime, so bringing one environment up cannot remove another's containers.
Those three separations are enforced by mechanisms that have nothing to do with the proxy: a network definition per environment, a data path outside the project folder, and a project name that each environment declares explicitly instead of letting the runtime derive it from the folder name. That last one matters because the container runtime treats folders with the same name as one project, so without it, starting one environment could remove the containers of another. The reason they matter here is subtractive: they mean the proxy has exactly one job. It does not have to prevent environments from reaching each other — that is already handled. It only has to route each incoming request to the right one.
D. What is at stake in getting it wrong
The proxy is also the one component where separation stops being possible. There is a single instance of it, and all three environments depend on it simultaneously.
That concentration is the risk. A mistake in the proxy's configuration does not stay inside the environment being worked on. A bad edit while setting up staging can take production offline, because both are served by the same process reading the same configuration directory — and on this machine, so are other projects entirely unrelated to this one.
Which is why the part of this article I would defend most strongly is not the routing. It is the question of who is allowed to change that configuration, and what happens when the answer turns out to be "anything that rewrites files in a directory" — including a version control command that nobody thought of as dangerous.
E. What the proxy has to get right
Three things, and they pull against each other.
It has to route correctly: a request meant for staging must never reach production. It has to be editable: addresses change, and a person has to be able to change them without re-deriving the whole configuration. And it has to be recoverable: when something is wrong at two in the morning, there must be a way back to a known state.
The obvious design satisfies the first two and quietly destroys the third. That is the part worth telling.
F. Four obstacles in the way
Stated as a goal: serve three environments from behind a single domain name I had no authority to change, with no request ever reaching the wrong one. The name is fixed because it is already written into certificates that have been issued, into configuration held by organisations I do not administer, and into documents that have been signed — where the name is part of what was attested. Everything else was negotiable. That was not.
Easy to state, awkward to build. Four obstacles stood in the way, and they arrive in the order a person actually meets them: first the confusion of looking at three identical things, then two obstacles about how the configuration gets written, and finally the one that cost the most.
The first is that one domain means nobody can tell which environment they are looking at. This sounds like a cosmetic problem and is not. When the address bar reads the same in all three, the only thing distinguishing a rehearsal from the real system is the memory of the person looking at it — and that memory fails exactly when it matters, late in the evening, on the third tab. The usual fix, a distinct hostname per environment, is the thing this article cannot have. So the marker has to be injected into the page itself: a coloured band across the top of every response, blue for development and orange for staging, naming the environment and reminding the reader that its data is not real. Production gets no band at all, and that asymmetry is deliberate rather than an omission — a marker that leaked into the real system would be a defect of its own.
The second is that five nearly identical configuration files are not a configuration strategy but a promise to keep five things in sync forever. Each environment had its own complete file, and the laptop and server variants were separate files again. Measuring what actually differed was sobering: entire server blocks identical, several route definitions identical, two differing by a single line. What genuinely varied was a listening address, a banner and a security header. Such a promise breaks quietly — a route block can go missing from a single environment during a reorganisation and stay missing for a long time, because the environment it vanished from is the one nobody visits daily. Copying a file per environment is the alternative, and it is exactly what invites that defect.
The third is that generating configuration for one language with a tool from another produces output that is valid and wrong. Web server configuration is full of runtime variables written with the same sigil a shell uses — $uri, $host, $remote_addr — and they are meant to reach the final file untouched. The obvious renderer, plain environment substitution, treats them as its own, finds no values, and replaces them with nothing. What comes out is still syntactically valid. The configuration test passes, the server reloads without complaint, and the fault surfaces only when a particular kind of request arrives. The answer was to stop sharing a sigil at all: placeholders are written @@NAME@@, a sequence that cannot occur in a valid configuration file, which makes an unsubstituted placeholder something the renderer can search for and refuse to install.
The fourth is that a file which is both tracked by version control and live system configuration has two owners that do not coordinate. Keeping the real file in the repository and placing a symbolic link in the system directory looks like good practice — one copy, tracked, editable like any other project file. It can work for months, until a branch switch rewrites the working tree and, through the link, the live configuration of the proxy serving production. The checkout succeeds, an endpoint begins returning 502, and nothing in the version control output suggests anything outside the repository has changed, because as far as it knows, nothing has. Version control's job is to make the working tree match a revision, and it performs that job perfectly, including when the working tree is load-bearing.
The order matters in a specific way. The first obstacle is the one a person meets within seconds of the domain being shared, and it is solved once and then forgotten. The second and third belong together: both are about how a configuration file comes into existence, and the third only becomes possible because the second pushed me towards templates in the first place. The fourth is last because it is the one that destroys recoverability — the third of the three requirements above — and unlike the others it does not announce itself as a design question at all. It arrives as a routine command that has been safe every previous time.
Method
A. Separate at the socket, not at the name
This is the answer to the routing half of the problem, and the foundation the other three obstacles are solved on top of.
If the name cannot distinguish environments, something else must. The answer was the address the server listens on.
A machine usually has more than one address available to it: the loopback address that only it can reach, an address on the local network, an address on a private network interface. A web server can be told to accept connections on a specific one:
listen 127.0.0.50:443 ssl; # development — reachable only from this machine
listen 10.0.1.10:443 ssl; # staging — reachable over the private network
listen 10.0.2.10:443 ssl; # production — reachable from the office networkThree server blocks, same server_name, three different sockets. The request arrives at one address or another, and that is what decides which environment answers. The name never has to be different because the name is not what is being matched.
This has a property I did not appreciate until later: the separation is enforced by the network, not by configuration. Development listens on an address that exists only inside the machine. No amount of misconfiguration elsewhere makes it reachable from outside, because there is no route to it.
B. Choosing an environment is choosing an address
If the environments differ by address, then switching between them in a browser means resolving the same name to a different address. On a workstation that is the hosts file — the small table the operating system consults before asking a DNS server.
Editing it by hand works and invites mistakes, mostly of one kind: leaving two entries active, in which case the resolution order decides the environment and the answer is whatever the system feels like. So the switch is a command, and it guarantees three things: exactly one entry for the domain is active, any duplicate entry elsewhere in the file is disabled, and lines belonging to other projects are untouched.
# --- managed block: exactly one line active ---
#10.0.1.10 app.example.test
#10.0.2.10 app.example.testThe addresses are not written into the switching tool. It reads them from the same file that the proxy configuration is generated from, so there is one place where an address lives and everything else follows it.
C. The configuration that version control could edit
This is the answer to the fourth obstacle — the one that arrived as an ordinary command rather than as a design question.
Web server configuration lives in a system directory. My configuration was generated from the project, so the arrangement that seemed obvious was to keep the real file in the repository and put a symbolic link in the system directory pointing at it. One copy, tracked in version control, edited like any other project file.
It worked for months. Then I switched branches.
# the arrangement that looked obvious
# ... so this rewrote the live configuration
A branch switch rewrites the files in the working tree. With a link in place, one of those files is the live configuration of the web server serving production. The checkout succeeds, silently, and leaves the proxy pointing at addresses from an older revision. An endpoint begins returning 502, and nothing in the version control output suggests that anything outside the repository has changed, because as far as it knows, nothing has.
The lesson is not "be careful with symbolic links". It is narrower and more useful: a file that is both tracked by version control and live system configuration has two owners, and they do not coordinate. Version control's job is to make the working tree match a revision. It will do that job perfectly, including when the working tree is load-bearing.
So there is no link. The configuration in the system directory is an ordinary file, owned by root, rendered from a template in the project rather than linked to it. Version control cannot reach it.
That comes with a consequence worth stating plainly: restoring the configuration is not a single version control command. Reverting the template reverts the template. The rendered file stays as it was until it is rendered again.
D. One template instead of five near-identical files
This is the answer to the second obstacle: five files that have to agree with each other, and drift apart the moment one is edited alone.
The measurement above points at the shape of the fix. If almost everything is identical and only a listening address, a banner and a security header genuinely vary, then the identical parts should not be stored five times at all.
Shared blocks became shared partials — fragments of configuration kept in their own files and pulled into each template at render time, so that a block which must be identical everywhere physically exists in only one place. A block that exists once cannot go missing from one of three copies.
# dev.conf.in — the only lines that differ per environment
listen @@LISTEN@@:443 ssl;
include partials/app-routes.conf; # identical in all three
include partials/ca-server.conf; # identical in all threeE. The substitution that passes the syntax check and is wrong
This is the answer to the third obstacle, and it is the one that only exists because of the previous section — templates are what made a renderer necessary.
Rendering a template means substituting values into placeholders, and the obvious tool for that is the standard environment-substitution utility. It is also, in this specific case, dangerous.
Web server configuration is full of runtime variables that use the same sigil a shell does — $uri, $host, $remote_addr — and they are meant to survive into the final file untouched. A naive substitution does not know that. It treats them as shell variables, finds no values, and replaces them with nothing:
try_files $uri $uri/ /index.html; # intended
try_files / /index.html; # after naive substitutionThe second line is still syntactically valid. The configuration test passes. The server reloads without complaint and then behaves incorrectly, in a way that only shows up when a particular kind of request arrives.
The answer is to stop sharing a sigil with the target language. Placeholders are written @@NAME@@, a sequence that never occurs in a valid configuration file. That property is what makes verification possible: after rendering, the renderer searches for any remaining @@ and refuses to install the file if it finds one. An unsubstituted placeholder becomes a hard failure instead of a silent one.
This is a general shape worth recognising. When you generate configuration for one language using a tool from another, the two languages' syntaxes will eventually collide — and the collision will produce output that is valid and wrong, which is the worst combination available.
F. Failing before touching anything
The renderer does its work in an order chosen so that a failure leaves nothing half-changed: render to a temporary location, verify the result, back up the current file, install, test the server's own configuration check, and restore the backup if that check fails.
The ordering matters more than the steps. Testing before installing means a broken render never reaches the system directory. Backing up before installing means there is something to go back to. Restoring on failure means a failed run leaves the machine exactly as it was.
&& || G. Marking the environments a name cannot distinguish
This is the answer to the first obstacle, and it is the only one that solves a human problem rather than a machine one. Everything above makes the three environments genuinely separate. None of it makes them look separate, and the address bar reads identically in all three.
The marker is injected by the proxy itself, on the way out, rather than by the application. That placement is the whole point: the application does not need to know which environment it is running in, cannot get it wrong, and cannot be deployed to the wrong place with the wrong value compiled into it. The proxy already knows, because the environment is decided by which socket the request arrived at.
# dev.conf.in — injected on the way out, by the proxy, not the application
add_header X-App-Env "dev" always;
sub_filter_once off;
sub_filter '</body>' '
<div style="position:fixed;top:0;left:0;right:0;z-index:99999;
background:#2563eb;color:#fff;text-align:center;
font:600 13px/28px system-ui;height:28px;">
DEV · local · development environment
</div>
<div style="height:28px"></div>
</body>';Two details are less obvious than the band itself. The second <div> is a spacer of exactly the band's height: a fixed-position element is removed from the document flow, so without it the band would sit on top of whatever the page renders first. And sub_filter_once off matters because a response can contain more than one closing body tag in practice; substituting only the first leaves pages where the band silently does not appear.
Staging differs in three values — an orange band, the word STAGING, and a version identifier rendered from a small file that the environment generator rewrites whenever the staging version changes. Keeping that identifier in its own file is what allows the template itself to stay static.
# staging.conf.in — same mechanism, three different values
add_header X-App-Env "staging" always;
sub_filter '</body>' '
<div style="…background:#e67e22;…">
STAGING · $app_staging_version · test environment, not production data
</div>…';Production has neither the band nor the header, and that is a decision rather than an oversight. A marker exists to tell a developer that what they are looking at is not real; in production there is nothing to warn about, and an environment banner appearing in front of actual users would be a defect. The header is omitted for a related reason — it announces to anyone who asks what kind of system they have reached, which is information the real system has no reason to volunteer.
It is worth noticing what this shares with the previous sections. The band is a difference between environments, so by the logic of section D it belongs in the per-environment template rather than in a shared partial — and it is one of the three things that genuinely varied when I measured. The measurement is what told me where to put it.
Results and Discussion
A. The check that does not check what you assume
Every web server offers a configuration test, and it is genuinely useful. It is also narrower than its reputation.
The test parses the configuration and validates its structure. It does not attempt to bind to the addresses the configuration names. So a configuration that listens on an address the machine does not currently have — a private network interface that is down, a local network address that changed — passes the test cleanly.
The failure arrives later, and only under one specific action. A reload keeps existing listening sockets and will not notice. A restart releases them and tries to acquire them again, which is when the missing address becomes a startup failure. The proxy that has been running fine for weeks refuses to start, and the configuration that "tested fine" is the reason.
The tool I use now reports, separately from the syntax check, whether each configured address actually exists on an interface. It is a short check, and it asks the machine rather than the configuration — which is the only way to catch a disagreement between the two.
# Does every address this configuration listens on exist right now?
| |
while ; do
|
&&
|| It is the sort of check that looks redundant until the first time it saves an evening.
B. The header nobody would think to check
One detail in the proxy configuration causes a failure that would take a day to trace without already knowing where to look.
The object storage service verifies upload requests by computing a signature over parts of the request — including the host it believes it was addressed as. When the proxy forwards a request, it must forward that value as the storage service expects it, or the signature it computes will not match the one the client sent.
Get it wrong and the failure is a signature mismatch, reported by the storage service. It is not a proxy error. Nothing in the proxy's logs is unusual. The request is forwarded correctly by every measure the proxy knows about, and the component that rejects it is three layers away from the line that caused it.
This is the argument for generating configuration rather than writing it: not that generation is more elegant, but that a value like this one is easy to omit by hand and impossible to omit from a template that always emits it.
C. What one proxy for three environments actually costs
This design concentrates risk on purpose, so it carries guards of its own. One proxy serves all three environments, so a configuration error in it could reach an environment other than the one being worked on.
The render-test-install-rollback sequence means a bad configuration is rejected before it is installed, and the separation into partials means an edit to shared behaviour is made once rather than three times.
I prefer that to the alternative failure — five hand-maintained files drifting apart — because the drift is far harder to detect. A shared mistake is visible everywhere immediately. A divergence is visible only where someone looks.
D. Where this does not apply
If you can give each environment its own hostname, do that instead. Everything in this article is a consequence of not being able to, and none of the complexity is worth adopting voluntarily.
If your environments run on separate machines, most of this dissolves too — each machine gets a straightforward configuration, and the only shared component is DNS.
What generalises is narrower: configuration that is generated should be verified for completeness before installation, installed atomically with a way back, and never made reachable by a tool whose job is to rewrite files. Those hold regardless of how many hostnames you have.
Conclusion
One domain reaching three environments comes down to a single idea: separate at the socket rather than at the name. Once the addresses differ, everything else — switching, routing, certificates — follows from that one decision.
The rest of the work was about a different question: who is allowed to change live configuration. Linking answers "whatever edits the repository", which is the same as saying "anyone running a routine version control command". Rendering rather than linking narrows that to one deliberate action, which is what makes the configuration recoverable at two in the morning.
Two failures in this work share a shape worth carrying away. A substitution that produces valid, wrong output. A syntax check that validates structure but not reachability. Both pass their tests. Both are wrong. The verification that earns its place is the one that asks whether the result is complete and whether the world it assumes actually exists — not merely whether it parses.
Terms
- Reverse proxy
- A web server that accepts requests from outside and forwards each to whichever internal service should handle it.
- Listening address
- The specific network address a server accepts connections on. Using different ones for different environments separates them without needing different names.
- Loopback address
- An address reachable only from the machine itself, which makes it a natural home for a development environment.
- Hosts file
- A local table mapping names to addresses, consulted before a DNS server. Editing it is how a workstation chooses which environment a name reaches.
- Template rendering
- Generating a final configuration file by substituting values into a skeleton, as opposed to editing the final file directly.
- Partial
- A fragment of configuration shared by several templates, so that a block which must be identical everywhere exists in only one place.
Technologies covered
- Bash
- Linux
- Docker
- nginx
- TLS