“Push it to staging first.” “Is that in prod yet?” “Did you try it in TEST?” Everybody talked like that, as if it were obvious. And it is obvious, once you know. Before that it's a pile of acronyms.
What blocked me wasn't the concept. I understood, more or less, that there was a server for messing around and a server for real. What I couldn't picture was what that meant inside the code. Do I keep two copies of the file? Two branches? Change the address by hand before deploying? (Spoiler: I did all three, at different points.)
So here's the version nobody gave me:
- DEV is your machine. Break whatever you want.
- TEST is where the test suite runs. Throwaway database, made-up data, born and killed on every run. The thing using TEST is a machine.
- HML, homologação, is the closest copy of production you can manage, where people validate before release. That acronym is a very Brazilian thing; most of the world says staging, or UAT. Same animal.
- PRD is where your customers are.
Your machine
Only you ever see what happens here.
The test suite
Throwaway database, fake data, run by a machine.
Staging
A copy of production, validated by people.
Production
Where your customers are.
The happy path. The change clears the tests, the team signs off in staging, and it goes live at an agreed time. This is what happens most of the time, and nobody ever mentions it.
The automation went into a loop and fired email nonstop. Here that costs a red build and twenty minutes. Without TEST, it costs every customer's inbox.
The code is right; the rule is wrong. A new required field blocks the form for half the customers, and only the support manager would ever know that. They caught it on a Tuesday instead of on go-live night.
Twenty R scripts and one file path
Before I understood any of those acronyms, I had already paid dearly for them.
My first serious project was an ETL written in R. Around twenty script files, each one pulling a spreadsheet, cleaning it, joining it, spitting out a result. It worked. It ran beautifully. On my machine.
Then I had to run it on another machine.
The file paths were baked into the middle of the code. Not in one place, in twenty. C:/Users/leandro/Documents/dados/... scattered across functions I had written months earlier and barely remembered. I spent the day on Ctrl+F swapping them one by one. Run, crash, find another. Swap, run, crash again. The worst one lived inside a function that only got called at the end of the process, so the error showed up forty minutes after the start. Forty minutes to find out a slash was missing.
None of that was R's fault. That was me mixing two things that have nothing to do with each other: what the program does, and where it happens to be running.
The Pragmatic Programmer
A few years later I read The Pragmatic Programmer, by Hunt and Thomas, and got to the part about configuration. The argument is simple: anything that can change without the logic changing should not live inside the logic. File paths, credentials, URLs, ports, retry counts, timeouts.
The way I read it, the rule is this: if you have to touch code to run the program on another machine, something is stored in the wrong place.
Made complete sense. The funny part is that I had already learned it the hard way, with interest, years before. Reading it just put a name on the mess.
Knowing the why didn't hand me the how, though. My first attempt was the one everybody makes:
if ENV == "HML":
BASE_URL = "https://hml.api.empresa.com"
DB_HOST = "servidor-hml"
TIMEOUT = 60
elif ENV == "PRD":
BASE_URL = "https://api.empresa.com"
DB_HOST = "servidor-prod"
TIMEOUT = 10
It beats hardcoded paths, no question. But it ages badly. Every new setting is one more if. And the ifs don't stay put in one file, they leak. Six months later there's an if ENV == "PRD" sitting in the middle of the function that sends email, and you only find it because a real customer got a test message.
Miguel Grinberg's pattern
What made it click for good was Miguel Grinberg's book on Flask. In the configuration chapter he does something simple and beautiful: each environment becomes a class.
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY")
TIMEOUT = 30
@staticmethod
def init_app(app):
pass
class DevelopmentConfig(Config):
DEBUG = True
BASE_URL = "http://localhost:8000"
DATABASE_URL = os.environ.get("DEV_DATABASE_URL")
class TestingConfig(Config):
TESTING = True
BASE_URL = "http://localhost:8000"
DATABASE_URL = "sqlite://"
class StagingConfig(Config):
BASE_URL = "https://hml.api.empresa.com"
DATABASE_URL = os.environ.get("HML_DATABASE_URL")
TIMEOUT = 60
class ProductionConfig(Config):
BASE_URL = "https://api.empresa.com"
DATABASE_URL = os.environ.get("DATABASE_URL")
TIMEOUT = 10
config = {
"development": DevelopmentConfig,
"testing": TestingConfig,
"staging": StagingConfig,
"production": ProductionConfig,
"default": DevelopmentConfig,
}
And when the app boots, one line:
app.config.from_object(config[os.environ.get("APP_ENV", "default")])
The answer to “what changes between environments?” fits in one file. You open it and you see it. No hunting.
Inheritance does the boring work. What's shared lives in the base class and nobody repeats it. What differs gets overridden, and only what differs shows up in the subclass. If TIMEOUT isn't written in DevelopmentConfig, that's because the base value applies, and that silence is information.
The rest of the code never asks which environment it's in. It reads app.config["TIMEOUT"] and gets on with its life. The if turned into a dictionary lookup and went away without anyone needing discipline. A new environment is a new class and one more line in the dictionary.
And the secrets never live in the file. os.environ.get means the file describes the shape and the machine fills in the value. So you can commit the file without committing the password.
I've followed this in every project since. The language changes, the idea doesn't. In TypeScript it becomes one object per environment plus a type that guarantees they all carry the same keys. In .NET it ships built in, with appsettings.Production.json overriding appsettings.json, which is the same inheritance wearing a different name.
One more warning from the same book, which I've written about here before: don't glue your code to the IDs of the environment it runs in. Sandbox and production usually carry the same fields, but each field has a different ID in each place. Let the code discover those IDs on its own.
This isn't only about code
These days most of my work is CRM. And the same story shows up there, with a price tag on it.
Honest heads-up first: Red Lotus is a Freshworks partner, so read the recommendation knowing that.
Most CRM vendors treat the sandbox as a premium feature, released only on the Enterprise plan. This is one of the few things I have a firm opinion about: a company that takes quality seriously and doesn't want to disrupt its customers should go Enterprise. The sandbox alone pays the difference.
Three reasons, and none of them is technical.
Speed. When the only place to build is production, every change becomes a negotiation. The team starts avoiding changes, and the CRM stops evolving. That's a far more expensive problem than the gap between plans, and it takes years before anyone notices they're paying for it.
Bugs before the users. An automation that loops and fires email nonstop is funny in the sandbox. In production it's an apology to your entire base and a full day of queue.
Governance. Before anything goes live, the stakeholders click it. The support manager sees the new form, the compliance person sees where the field lands, and the objection shows up with time to spare.
Now the part the sales rep leaves out.
A sandbox is not a clone. It doesn't come with your production data, and in most tools it doesn't sync back on its own. You promote the configuration by hand, or with a “sandbox sync” that covers some object types and quietly ignores others. Ask for that list before you promise anyone a deadline.
The integrations stay behind. The webhook, the middleware, the ERP's API: each one needs a test environment of its own, and plenty of vendors simply don't have one. So you end up testing against somebody else's production, which is the exact thing you were trying to avoid.
And the price is real. Enterprise is not a small step up, and if you want the numbers I went through the Freshdesk Omni plans one by one. If you're four people with one email queue, the honest answer is no. The argument holds when there's something to break: volume, integrations, a process people depend on.
And there's still rollback
Don't be naive. Even with four environments, a green pipeline, a signed-off staging round and a scheduled go-live, once in a while a deploy gets rolled back. Something that only shows up with real volume and a real user doing what no test plan predicted.
The difference is that it becomes rare, and when it happens nobody is figuring out the procedure on the spot. There's a previous version, there's a plan, someone presses the button. The customer gets a bad half hour and that's where it ends.
My R ETL never had staging of any kind. If I went back today I'd start with the config file before writing the first script, and it would still break somewhere I hadn't predicted. This is the nature of software.