Production systems don’t usually fail because of missing tools.
They fail because too much happens implicitly.
A merge triggers a deploy.
A fix goes live unintentionally.
Weeks later, no one is entirely sure what version is actually running.
This article documents a deliberate shift I made in how production releases are handled—moving from implicit deployment behavior to explicit, intentional releases using Git tags and infrastructure templates in Azure DevOps.
This wasn’t about adding complexity.
It was about removing ambiguity.
The Problem I Wanted to Solve
Before the change, the release model had familiar weaknesses:
- Merges to
mainwere tightly coupled to deployment - Production changes could happen without a conscious “release decision”
- Version visibility in production was inconsistent
- Pipelines mixed application logic and platform concerns
None of this caused daily failures—but it created latent risk.
The question I asked was simple:
How do I make production boring, predictable, and explainable?
The Guiding Principles
Instead of starting with tooling, I started with principles:
- Production changes must be intentional
- Releases must be immutable and auditable
- Application code and platform logic should not live together
- Developers should not need to understand deployment internals
- The system should scale from solo to enterprise without redesign
Everything else followed from these.
The Core Decision: Tag-Based Releases
The single most important change was this:
Production deployments are triggered only by Git tags.
Not by merges.
Not by branch updates.
Not by UI clicks.
A release now requires an explicit action:
git tag vX.Y.Z
git push origin vX.Y.Z
That’s the moment a human says: “This is production.”
Separating Responsibilities with Repositories
To support this model cleanly, responsibilities were split across two repositories:
Application Repository
- Contains UI, APIs, and business logic
- Has a single, thin pipeline entry file
- Decides when to release (via tags)
Infrastructure Repository
- Contains pipeline templates and deployment logic
- Builds and deploys applications
- Defines how releases happen
This separation ensures:
- Platform evolution doesn’t pollute application repos
- Multiple applications can share the same release model
- Infrastructure changes are treated as infrastructure—not features
Pipelines as Infrastructure, Not Code
A key mindset shift was treating pipelines as platform infrastructure.
That meant:
- Pipeline entry files are locked behind PRs
- Changes are rare and intentional
- Developers generally don’t touch them
- Deployment logic lives outside the app repo
This immediately reduced accidental breakage and cognitive load.
Versioning: Moving from Build-Time to Runtime
Once releases were driven by tags, traditional assembly-based versioning stopped being useful—especially for static web applications.
Instead, version information is now injected at build time into a runtime artifact:
/version.json
Example:
{ "version": "v2.0.5" }
The application reads this file at runtime to display its version.
This approach:
- Works cleanly with static hosting
- Reflects exactly what was released
- Is easy to extend with commit hashes or timestamps
- Decouples versioning from build tooling
The Day-to-Day Experience
After the setup, daily work became simpler—not more complex.
- Developers work in feature branches
- Code is merged into
mainwithout fear - Nothing deploys automatically
- Production changes require an explicit tag
Releases are boring.
And that’s exactly the goal.
Rollbacks and Auditability
Because releases are immutable:
- Redeploying a version is trivial
- Rollbacks are predictable
- There’s always a clear answer to: “What code is running in production?”
This is especially valuable in regulated or client-facing environments.
Tradeoffs and Honest Costs
This approach isn’t free.
Costs:
- Initial setup takes time
- Azure DevOps YAML has sharp edges
- Pipelines must exist before tags will trigger
- Early experimentation may require tag resets
Benefits:
- Zero accidental prod deploys
- Clear ownership and accountability
- Clean separation of concerns
- Reusable platform foundation
- Long-term operational confidence
For long-lived systems, the tradeoff is worth it.
When This Pattern Makes Sense
This model works best when:
- Production stability matters
- Systems are long-lived
- Auditability or compliance is a concern
- Teams want clarity over convenience
It’s less suitable for:
- Hackathons
- Throwaway prototypes
- “Merge = deploy” cultures
The Leadership Lesson
The most important takeaway wasn’t technical.
Good systems make intent explicit.
Great systems remove ambiguity from critical outcomes.
Production safety doesn’t come from moving slower.
It comes from designing systems where important changes happen on purpose.
Final Thoughts
This wasn’t about Azure DevOps specifically.
The same principles apply anywhere.
If you can answer these questions clearly, you’re on the right path:
- Who decided this went to production?
- When did that decision happen?
- What exactly was released?
If those answers are obvious, production becomes boring.
And boring production is a feature.

Add to favorites
