When to Leave Heroku
Assess cost, control, reliability, and scaling needs before leaving a PaaS.
Azure DevOps can get messy fast when a team is under pressure to ship. Someone creates a pipeline by hand, another person grants admin access so a release can go out, secrets land in plain variables, and nobody knows how to promote the same build to production safely.
A good Azure DevOps (ADO) setup does not need to be huge. It needs to let a developer merge code, run continuous integration (CI), deploy to a non-production environment, promote the same build to production with approval, and trace failures quickly when something breaks.
This guide assumes you are a startup or growth-stage team using, or considering, Azure DevOps for source control, pipelines, release controls, and cloud deployments. The goal is a setup that is safe enough for production without creating a process that slows every engineer down.
Before you create projects, agents, service connections, and environments, decide how work should move through your system. Most ADO problems come from unclear ownership rather than missing features.
For a small team, use a simple model:
At an early stage, one ADO project is usually enough. Split by service only when you have a real ownership boundary, not because the UI looks cleaner. A common starting point is:
product-platform.If you are still choosing between Azure DevOps, GitHub Actions, GitLab CI, CircleCI, or another platform, step back and compare the tradeoffs before committing your deployment process to one vendor. This breakdown of how to choose the right DevOps tools for your team can help you avoid selecting a tool only because one engineer used it at a previous company.
ADO permissions tend to drift when teams are moving quickly. A developer gets Project Administrator access to fix one release. A contractor keeps broad permissions after the engagement ends. A production service connection can be used by every pipeline in the project. These shortcuts work until they do not.
Start with a few clear groups:
Use branch policies on your default branch. For most startups, the following is enough:
main./infra, /helm, or /pipelines.Screenshot to add: ADO Project Settings showing the main security groups, with Project Administrators limited to a small number of users.
Screenshot to add: Branch policies for main, showing required reviewers and build validation.
Do not give broad admin access because a pipeline is failing. Fix the identity, permission, or service connection that the pipeline actually needs. This takes longer the first time, but it prevents a permissions model where every engineer can accidentally deploy or modify production infrastructure.
Service connections are one of the most important parts of an ADO setup. They decide what your pipelines can touch in Azure, Kubernetes, container registries, and other systems.
Use separate service connections for non-production and production. For example:
sc-azure-nonprod-deploysc-azure-prod-deploysc-acr-push for pushing container imagessc-aks-nonprod and sc-aks-prod if you deploy to Azure Kubernetes ServiceRestrict each service connection to the pipelines that need it. In ADO, avoid enabling access for all pipelines unless you are dealing with a low-risk sandbox. Production service connections should require explicit authorization.
Screenshot to add: A production Azure Resource Manager service connection with restricted pipeline permissions and a clear naming convention.
For secrets, avoid plain pipeline variables for anything sensitive. Use one of these patterns instead:
Common mistakes include storing production database passwords in YAML, copying secrets into variable groups without access review, and using the same credentials for non-production and production. These choices usually happen because the first deploy needed to work quickly. Clean them up before more services depend on the pattern.
A simple secret rule works well: developers can deploy without seeing production secrets. They can trigger the deployment, review logs with sensitive values masked, and debug through application telemetry and configuration history.
A startup pipeline should be boring. Build once, test once, publish an artifact or container image, deploy that same version to non-production, then promote it to production with approval.
Avoid separate production builds. If production uses a different build than staging, you are no longer promoting what you tested. You are hoping the second build behaves the same way.
Here is a minimal Azure Pipelines YAML example for a containerized service. It builds on pull requests and main, pushes an image, deploys to a non-production environment, then waits for production approval through an ADO Environment check.
trigger:
branches:
include:
- main
pr:
branches:
include:
- main
variables:
imageName: my-api
dockerfilePath: Dockerfile
containerRegistry: myregistry.azurecr.io
stages:
- stage: CI
displayName: Build and test
jobs:
- job: build_test
displayName: Build and test
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
- script: |
npm ci
npm test
displayName: Run tests
- task: Docker@2
displayName: Build and push image
inputs:
command: buildAndPush
repository: $(imageName)
dockerfile: $(dockerfilePath)
containerRegistry: sc-acr-push
tags: |
$(Build.SourceVersion)
- stage: Deploy_NonProd
displayName: Deploy to non-production
dependsOn: CI
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: deploy_nonprod
displayName: Deploy non-production
environment: nonprod
strategy:
runOnce:
deploy:
steps:
- script: |
echo "Deploy $(containerRegistry)/$(imageName):$(Build.SourceVersion) to nonprod"
# Replace with Helm, kubectl, Azure Web App, or your deploy command
displayName: Deploy application
- stage: Deploy_Prod
displayName: Deploy to production
dependsOn: Deploy_NonProd
condition: succeeded()
jobs:
- deployment: deploy_prod
displayName: Deploy production
environment: production
strategy:
runOnce:
deploy:
steps:
- script: |
echo "Deploy $(containerRegistry)/$(imageName):$(Build.SourceVersion) to production"
# Replace with the same deployment mechanism used in nonprod
displayName: Deploy application
This example is intentionally small. In a real service, you may add linting, security scanning, database migration checks, smoke tests, and deployment templates. Add those when they reduce real risk. Do not create a 900-line pipeline on day one because a larger company had one.
Screenshot to add: Pipeline run summary showing separate stages for CI, non-production deployment, and production deployment.
If you deploy to Kubernetes, keep cluster upgrades and application deployment concerns separate. Application pipelines should deploy workloads. Cluster lifecycle changes should move through infrastructure pipelines with their own review path. If Kubernetes is part of your stack, these practical tips for Kubernetes upgrades for startups are useful when you start formalizing cluster operations.
ADO Environments give you deployment history, approvals, and checks. Use them for actual runtime targets, such as nonprod, staging, and production.
For non-production, keep the process fast. A successful merge to main should deploy automatically unless your team has a strong reason to pause. Developers need rapid feedback before production.
For production, add a simple approval check:
Screenshot to add: The production Environment approvals and checks page showing one required approver group.
Do not treat approvals as a replacement for tests, review, and observability. If every production deploy needs three people in a meeting, your process is too heavy. If anyone can deploy to production from any branch, your process is too loose. The middle ground is a tested build, a visible deployment record, and a production approval by someone accountable.
Also decide what happens when a deployment fails. At minimum, each service should have:
A rollback plan does not need to be complex. For many services, the first version can be: find the last successful production deployment in ADO, redeploy that image tag, and verify the health endpoint. Write it down in the repository so the person on call is not guessing at 2 a.m.
A pipeline that fails with no useful signal wastes engineering time. A deployment that succeeds while the service is broken is worse. Your ADO setup should make it easy to answer four questions:
Use consistent versioning. For container images, tag with the commit SHA, such as $(Build.SourceVersion). You can also add a shorter human-readable tag, but the commit SHA should be enough to map production back to source.
Add deployment annotations where your observability tooling supports them. If an incident starts five minutes after a deployment, the on-call engineer should see the deployment event near the error spike, latency change, or restart count.
Keep logs and metrics linked from the service README or runbook. A practical service README includes:
Alerting also needs restraint. If every failed non-production deployment pages the team, people will ignore alerts. Page on production user impact, failed production deployment requiring action, or capacity issues that need fast response. Send lower-priority signals to Slack, Teams, or the work tracker. If your team already gets too many noisy alerts, this guide on how to handle alert fatigue can help you clean up the signal before adding more checks.
Most ADO setups fail in predictable ways. You can avoid many of them with a few early decisions.
As the team grows, decide who owns the platform work. This may start as a rotating responsibility, then become a named owner, then become a small platform or DevOps function. The shape depends on your team size, incident load, and deployment frequency. If you are at that point, this guide on how to build a DevOps team can help you choose a structure without hiring too early or too late.
A solid ADO setup for a startup should be simple, repeatable, and hard to misuse. Developers should merge code, see CI results, deploy to non-production, promote the same artifact to production with approval, and trace failures without hunting through private notes or tribal knowledge.
Start with one clean path for one service. Add branch policies, scoped service connections, secret management, environments, approvals, rollback steps, and basic observability links. Then turn that path into a reusable pattern before every team invents its own.
If your current setup already has fragile pipelines, unclear permissions, or production deployments that depend on one person, it is worth fixing before the next scaling push. You can also get a second set of eyes through a DevOps setup for production consultation if you want help pressure-testing the path before more services depend on it.