"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.
The concept I sort of got: 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.)
The four environments:
- 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.
Side by side, so the acronyms stop blurring together:
| Environment | Who uses it | Data | What breaking it costs |
|---|---|---|---|
| DEV | The developer, alone | Whatever is on the laptop | Nothing. That is the point. |
| TEST | The pipeline, a machine | Made up, recreated on every run | A red build. Fix and rerun. |
| Staging / HML / UAT | People: QA, the business, the customer | A copy of production, ideally anonymised | A delayed release. Nobody outside notices. |
| PROD / PRD | Your customers | The real thing | Money, trust, a bad half hour for everyone. |
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.
The problem 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.
But the book didn't say how to do that in code. 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.
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 used this pattern in every project since, in whatever language. 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.
Sandbox in the CRM
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.
The reasons aren't 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. Getting that promotion right is a good part of what a Freshworks implementation partner gets hired for.
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
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.
Quick answers
What is the difference between test and staging?
TEST is for machines: the automated suite runs against a throwaway database and the environment is destroyed after every run. Staging is for people: a stable copy of production where QA, the business or the customer validate a release before it goes live.
Is staging the same as UAT or HML?
In practice, yes. UAT (user acceptance testing) and HML (homologação, the Brazilian term) name the same environment by what happens in it. Some teams keep a separate UAT for the customer and a staging for engineering, but most merge them.
Do I need all four environments?
DEV and PROD you already have. TEST appears the day you have automated tests. Staging pays for itself when there is something to break: real volume, integrations, or a process people depend on. A four-person team with one email queue can live without it for a while.
What is a sandbox in a CRM or helpdesk?
The vendor-hosted staging of a SaaS tool: a copy of your account's configuration, without production data, where you test automations and changes before promoting them. In Freshdesk it comes with the Enterprise plan.