The architecture diagram is immaculate. Managed database, managed broker, managed identity, infrastructure as code, the lot. It has been reviewed, approved, and put on a slide.

Then you open the repository and find docker-compose.yml. Postgres. RabbitMQ. Redis. A .env file somebody maintains by hand. A folder called mocks that has grown a maintainer.

Both of those things are true at the same time, and nobody experiences this as a contradiction. The diagram describes what we deploy. The Compose file describes what we develop. The gap between them is where the interesting failures live, and we only ever meet them in production, because production is the first place the two descriptions are forced to agree.

This is the pattern I keep running into, and I’ve stopped thinking of it as a tooling problem. It’s on-premises development wearing a cloud badge.

The uncomfortable part — the part that took me a while to accept — is that this is not what inexperience looks like. Junior developers use whatever the onboarding doc tells them to use. The local-first habit belongs to the senior people, because it’s built out of a decade of genuinely hard-won instinct: run it yourself, keep it close, don’t depend on infrastructure you can’t restart. Every one of those instincts was correct in a data centre. Several of them are now the reason the thing we tested isn’t the thing we built.

Good judgement, applied to the wrong decade, is still going to hurt you.

What follows is the list of habits, and what I think we should do instead. The examples lean AWS because that’s where I’ve spent most of my time, but none of the reasoning is AWS-specific — swap in Azure or GCP service names and the argument is unchanged.


Your Laptop Is the Workstation, Not the Environment

“I do my dev locally — it’s faster.”

If your company pays for an AWS Dev account, that account is your development environment. Not a deployment target you visit at the end of the sprint. The environment.

Put it in the same region as your users and your corporate network. Developing in Melbourne against ap-southeast-4, the path is:

Laptop → corporate network → AWS ap-southeast-4 (Melbourne) → Dev services

The latency on that path is low enough that the cloud behaves much more like an internal development network than the phrase “remote server” leads people to expect. Most of the intuition people have about remote development was formed on a VPN to a data centre on another continent, and it has not been updated since. Sydney’s ap-southeast-2 is close enough to be the fallback if a service you need isn’t in Melbourne yet; on Azure the equivalent is Australia Southeast.

Developing this way buys the single biggest advantage cloud development has to offer: you are building against the platform you are deploying to.

If production uses DynamoDB, develop against DynamoDB. If production uses SQS, develop against SQS. If it’s Cosmos DB and Service Bus, use Cosmos DB and Service Bus. What you should not do is quietly substitute:

  • local PostgreSQL
  • local MongoDB
  • local RabbitMQ
  • local Redis
  • Docker Compose
  • fake queues
  • mocked cloud services

None of that is faster development. It’s a miniature on-premises environment on your laptop, plus the hope that it behaves like the cloud.

Local still has its place — code with no cloud dependencies, offline work, quick experiments, and the cases where an official emulator genuinely does the job (DynamoDB Local is the honourable example: same API, same vendor, documented differences). The problem is local infrastructure as the default, chosen for no better reason than that the real infrastructure lives somewhere else.

Local code, real cloud services, a Dev account in the right region.

Your laptop is the development workstation. The cloud Dev account is the development environment. Conflating those two is where most of this begins.


The Broker You Test Is the Broker You Ship

“Let’s use RabbitMQ locally — everyone knows it.”

Production uses Amazon SQS. The developer runs RabbitMQ. Those are two different systems, and no amount of interface abstraction changes that.

RabbitMQ is not a bad message broker. Using it as a stand-in for the broker you’re actually shipping is the bad part. Delivery guarantees differ. Ordering semantics differ — a standard SQS queue does not promise order at all, and a FIFO queue is a different product with its own throughput limits. Dead-lettering differs. Visibility timeouts have no RabbitMQ equivalent. Retry and back-off behaviour differs. Throttling under load differs.

Every one of those differences is invisible until the system is under real load, which is to say: until customers are the ones discovering it. Your abstraction layer will faithfully hide the differences right up to the moment they matter, and then it will hide the cause too.

Do this instead: develop against the messaging service you’re shipping. And if RabbitMQ really is the right choice, choose it for production and run it properly — the anti-practice is the mismatch, not the technology.


Moving Complexity Is Not Removing It

“Cloud functions are too complex — I’ll use Docker.”

Serverless runtimes have real local tooling, built by the platform vendors, specifically so you don’t have to do this. Lambda has the SAM CLIsam local invoke runs your handler in the same runtime image Lambda uses. Azure Functions has Core Tools. They exist and they work.

Replacing the runtime with a hand-rolled Docker architecture because containers feel more familiar doesn’t remove complexity. It relocates it — out of a runtime that somebody else maintains, documents, patches and supports, and into a Dockerfile that you now own forever, alone, undocumented.

Do this instead: use the official local tooling, and integrate against the real services in your Dev environment.


Nothing Is as Permanent as a Temporary Deployment

“We’ll deploy this temporarily. We’ll fix it properly later.”

Watch the life of a temporary deployment:

temporary → feature → dependency → production → legacy

Not one step in that chain requires a decision. It happens on its own, by default, while everybody is busy with something else. The only conscious decision anyone ever makes is the first one — and it gets made with the word “temporarily” attached, precisely to lower the apparent stakes of making it.

By the time something else depends on it, “we’ll fix it properly later” has become a migration project with a budget and an owner who didn’t choose it.

Do this instead: before you deploy the temporary thing, ask the only question that matters.

“Would I be happy if this were still running in 18 months?”

If the answer is no, fix it now, while it’s cheap and nothing depends on it.


An Access Ticket Became an Architecture

“I don’t have cloud access — that’s why I started locally.”

This one deserves genuine sympathy, because it usually isn’t the developer’s fault. Someone needed to ship, the access request was sitting in a queue, and they did the reasonable thing with what they had.

But look at what got substituted for what. The problem was:

“I can’t access AWS.”

And the thing that got built was:

“Let’s not use AWS.”

An access ticket became an architecture. The ticket would have taken a week. The architecture will outlive several people’s employment, and nobody will remember it started as a permissions delay.

Do this instead: treat missing cloud access as the delivery blocker it is, and escalate it as one. Access problems get fixed in days when somebody insists. Architectures built to route around them get fixed in years, if ever.


Comfort Decisions in an Architecture Decision’s Clothes

“I don’t understand the cloud service — the old tech feels safer. I already know Postgres.”

I believe you. That still isn’t an architecture decision.

The tell is that the reasoning describes the state of your knowledge rather than any property of the system you’re building. “Postgres suits this access pattern” is an architecture argument. “I already know Postgres” is a fact about you, dressed up as a fact about the system.

Comfort decisions deserve to be taken seriously — unfamiliar technology carries real risk, and pretending otherwise is how teams end up operating something nobody understands. But the honest version of the argument is “I don’t know this yet,” and the honest response to that is to go and learn it. Ask a colleague. Read the docs. Find a working example. Pair with someone who’s done it. Use AI to compress the learning curve, which is one of the things it is genuinely excellent at.

Do this instead: spend two days learning the right technology rather than two years maintaining the wrong one. The exchange rate is not close.


“It Works” Is a Statement About the Present Tense

“If it works, there’s no need to fix it.”

It is also entirely compatible with:

  • operational overhead nobody accounts for
  • infrastructure you manage that someone else would manage for free
  • elaborate workarounds for a capability the managed service now ships
  • a runtime version approaching end of support
  • exactly one person who understands it
  • a migration cost that grows every quarter

Sometimes the right answer really is to leave it alone. Stability has enormous value, and rewriting working systems for aesthetic reasons is its own anti-practice. The problem isn’t leaving things alone — it’s leaving them alone by default, without ever having looked.

Do this instead: periodically ask:

“Would we choose this architecture today?”

“No, but it’s fine” is a perfectly good answer. “No, and I hadn’t thought about it” is the one to worry about.


The Model Wasn’t Confused. The Prompt Was.

“I asked Copilot and it generated the whole thing.”

You asked for this:

“Make it run locally.”

You got Postgres, RabbitMQ, Redis and a Docker Compose file.

Of course you did. You asked for a local system and it built you a local system, competently, in about four seconds. The model is not confused about cloud architecture. The prompt encoded the wrong architecture and the model implemented it faithfully, which is the one thing we all claim to want from these tools.

Try telling it what you’re actually building:

“Build a cloud-native solution using Lambda, DynamoDB and SQS, with local development through the SAM CLI and integration against our Dev account.”

Same tool, same four seconds, a fundamentally different result.

Think first, prompt second. And since this is now most of how code gets written:

You generated it → you understand it → you deploy it → you own it.

If you can’t explain what it does and deploy it yourself, it is not ready to hand to somebody else to operate at 3am.


Choosing an Architecture to Please a Tool

“Let’s do a monorepo — it’s easier for AI agents.”

This one is new, and I’m flagging it precisely because it sounds modern enough to pass unchallenged in a design review. Nobody wants to be the person who objected to the AI-friendly option.

It still isn’t an architecture reason. Repository structure should follow how software is built, owned and released. If services are developed and deployed independently, a polyrepo with properly versioned shared packages is often simpler and safer. If components genuinely belong together and release together, a monorepo may be exactly right — plenty of them are.

Both are defensible. “The AI likes it” is not one of the defences. We spent twenty years learning to structure repositories around teams and release boundaries; it would be a strange time to start structuring them around a tool’s context window.

Do this instead: choose repository structure for the architecture and the teams who own it. Tooling ergonomics — human or artificial — is a tiebreaker at most.


.gitignore Is Not a Security Architecture

“Secrets live in .env — it’s in .gitignore.”

.gitignore is a list of files Git ignores. That is a much smaller promise than the one being relied on.

It does nothing about the copy on your disk, the copy in the backup, the copy pasted into Slack while you were helping someone debug, or the afternoon someone renames the file and the pattern silently stops matching.

The platforms solved this. AWS has IAM roles, Secrets Manager and Parameter Store. Azure has Managed Identity and Key Vault. A role your code assumes is strictly better than a secret, because there is no secret to leak — and long-lived access keys in a .env file are the AWS-specific version of this mistake.

Do this instead: no credentials in source, config files or Compose files — including the ones that are “only for development.” Development credentials reach production surprisingly often, usually by being reused.


The Configuration Exists in Exactly One Place

“We’ll just click it up in the console.”

ClickOps genuinely is faster. It is faster right up until the first time you need to reproduce the environment, stand up a second one, recover from a disaster, explain why staging behaves differently from production, or work out who changed that setting and when.

At that point the configuration exists in exactly one place: the running system. The only way to read it is to click through the same forty console screens that created it, and the only record of intent is whatever people remember. CloudTrail will tell you who changed it; it will not tell you what they meant.

CloudFormation, CDK, Terraform — pick whichever suits the team.

Do this instead: infrastructure should be reproducible from code. The console is an excellent tool for operating and inspecting infrastructure. IaC is the source of truth.


“We Move Fast” Is Doing a Lot of Work

“Testing in prod is fine — we move fast.”

It isn’t fine, and that phrase is carrying an enormous amount of weight: no staging environment, no test coverage, and no rollback plan, compressed into three words that sound like a culture rather than an omission. Every deployment becomes a bet placed with customer data, and the customer finds out before you do.

Canary releases, progressive rollouts and feature flags are not testing in prod. They are controlled, monitored exposure of already-tested code, with a rollback path established before the first customer sees anything. The entire premise is that you already believe the code works and are limiting the cost of being wrong.

Conflating the two is how a legitimate release strategy becomes cover for skipping the testing — you get the vocabulary of sophisticated delivery practice and the risk profile of deploying on a Friday afternoon and going home.

Do this instead: test before it ships. Use staged rollouts to limit blast radius at release, not as a substitute for having tested.


The Questions Nobody Asked

Almost every habit above survives for the same reason: nobody said it out loud in a room with other engineers in it. Local Postgres doesn’t get proposed and defended. It gets committed on a Tuesday.

  • Am I using a local service because it’s genuinely needed, or because I’m avoiding the cloud?
  • Could I use the organisation’s Dev account instead?
  • Is the Dev environment in the right region?
  • Am I using the same managed services I’ll use in production?
  • Am I solving an access problem by creating an architecture problem?
  • Am I choosing this technology because it’s right, or because I already know it?
  • Did I give my AI assistant the right architectural context?
  • Am I choosing a monorepo for architectural reasons, or because AI likes it?
  • Can I explain and deploy the code I generated?
  • Is my infrastructure reproducible from code?
  • Would we choose this architecture today?

None of these are hard questions. That is rather the point.


What It Actually Costs

The obvious cost is the incident — the release that behaved differently in production because production was the first environment where the real services were involved. That one is at least visible. Somebody writes it up.

The expensive cost is quieter.

You paid for a cloud migration and you are still running an on-premises engineering culture. You are paying a cloud bill and maintaining Compose files, mocks, seed scripts, and a local environment that breaks for one developer every sprint for reasons nobody can reproduce. You have all the operational overhead you migrated to escape, plus the bill.

Your team’s expertise is accumulating in the wrong place. After two years, they are excellent at running Postgres and RabbitMQ locally and have never once dealt with a DynamoDB throughput exception, an SQS visibility timeout expiring mid-handler and redelivering the message, or an IAM role that works in Dev and not in Test. That knowledge only comes from using the thing, and every mock defers it.

And the moment when the mismatch surfaces is always the worst available moment. Not during development, when it’s a bug. During release, when it’s an incident — with an audience, and a rollback, and someone senior asking how this wasn’t caught.

The diagram was right the whole time. Nobody had run it.

We build cloud-native solutions. Let’s develop them that way.