DevOps Is Bullshit: The Illusion of 'You build it, you run it' and the Rise of Platform Engineering

DevOps Is Bullshit: The Illusion of 'You build it, you run it' and the Rise of Platform Engineering

Table of Contents

A backend engineer needs an IAM role so their service can read the S3 bucket holding customer invoices. They open Jira, pick the “Infrastructure Request” template, fill in twelve fields, attach the exact labels the README tells them to attach, and hit Create. Three days later, the role exists.

They did not sit idle for those three days. They wrote code against an environment variable pointing at an old role, it worked in staging because staging uses their own credentials, and they shipped it to production with a permission nobody will ever be able to explain the origin of.

The team that processed the ticket is called “DevOps”.

The company next door works the opposite way. No tickets, no queue, nobody to ask. The backend engineer writes their own Terraform, provisions their own RDS instance, configures the security group, maps the DNS record. Very “you build it, you run it”. Right up until 2 AM, when CloudWatch pages them because a connection pool is exhausted over a missing Postgres index, and the person being woken up is the same person who wrote 400 lines of business logic two weeks ago.

Both companies call themselves DevOps. Both are doing it wrong. And the failure is not a matter of laziness. It comes from an idea that got compressed into a job title, and then the job title got compressed into a queue.

1. DevOps was never a job title

In 2009 Patrick Debois organized DevOpsDays in Ghent. The core idea was not tooling. It was tearing down the wall between the people writing code and the people running systems, so that both groups carried responsibility for one thing: a product that works for users.

Around the same time, Werner Vogels at Amazon said “you build it, you run it”. In the context of Amazon in 2006, that sentence was liberating. It told teams they could not throw code over a wall at somebody else, that operational quality was part of the definition of done.

The catch is that Amazon said it with a very specific support structure behind it: AWS itself, shared internal tooling, and an organization where every team had engineers with real infrastructure competence. Everybody else took the slogan and left the support structure behind. That is how two distinct flavors of misery ended up sharing the same label.

2. Trap one: the “DevOps team” is a sysadmin group with new branding

The first failure mode is standing up a DevOps team. It sounds reasonable: gather everyone who understands infrastructure into one place and give them a modern name. Now look at what that team actually does all day.

They use Terraform and YAML to do manual work for the engineering org. Need a database? File a ticket. Need an IAM role? File a ticket. Need a port opened to the outside? File a ticket. A four-person DevOps team serving 120 engineers drowns in backlog, and every ticket resolved is another round of somebody reading a human-language request, translating it into HCL, opening a pull request, waiting for review, running plan, running apply, and replying “done”.

That is not DevOps. That is the classic ticket-driven ops model wearing new clothes. The wall between Dev and Ops did not come down. It got a fresh coat of paint and a revolving door installed.

graph TB
  subgraph "DevOps Silo (Anti-Pattern)"
    Eng["`Product Engineer
- Needs an IAM role for S3
- Needs an RDS instance
- Does not know which VPC is in use`"]
    Ticket["`Jira / ServiceNow
- 12-field template
- Nobody reads it fully`"]
    DOps["`DevOps Team
- 4 people for 120 engineers
- Manual Terraform + YAML
- 200+ ticket backlog`"]
    Infra["`Cloud Resources
- IAM, KMS, VPC, RDS, SQS
- Ad-hoc naming
- No guardrails`"]
  end

  Eng -->|"Files a ticket"| Ticket
  Ticket -->|"Waits 3 days"| DOps
  DOps -->|"terraform apply"| Infra
  Infra -->|"Result posted as a comment"| Eng
  DOps -.->|"Gatekeeper at every phase"| Eng

  style DOps fill:#1e1e2e,stroke:#f38ba8,stroke-width:2px

The real bill for one ticket

The expensive part is not the three-day wait. The expensive part is what happens during it.

A blocked engineer does not cleanly switch to other work, because the half-finished feature still occupies working memory. They reach for a temporary workaround: personal credentials, a hard-coded ARN, or a secret stuffed into an environment variable. The ticket gets closed, but debt was just created, and that debt will never appear in anybody’s backlog.

This failure mode also has a subtler organizational consequence. When every infrastructure change must pass through a single team, that team becomes an information chokepoint. Nobody outside it knows what the infrastructure looks like. Knowledge of production configuration turns into a personal asset held by a handful of people, and when those people leave, the system loses its map. Cory O’Daniel notes in the original piece that he deployed more than 200 production Kubernetes clusters and copy-pasted the same Terraform modules for all of them. The job felt like a scam, but it points at a truth: most “infrastructure work” outside FAANG is manual repetition, not design.

The code of repetition

Here is the HCL a DevOps team rewrites for every service, in every environment. It is not wrong syntactically. It is wrong economically.

# infra/services/order-api/prod/main.tf - copy number 47 of the same file
module "order_api_queue" {
  source     = "../../../modules/sqs"
  name       = "order-api-queue-prod"
  visibility = 30
  dlq        = true
  tags       = { team = "payments", env = "prod" }
}

resource "aws_iam_role" "order_api" {
  name = "order-api-prod-exec"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_policy" "order_api_s3" {
  name = "order-api-prod-s3"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
      Resource = [
        "arn:aws:s3:::invoices-prod",
        "arn:aws:s3:::invoices-prod/*",
      ]
    }]
  })
}

resource "aws_kms_key" "order_api" {
  description             = "order-api-prod encryption"
  enable_key_rotation     = true
  deletion_window_in_days = 7
}

resource "aws_cloudwatch_metric_alarm" "order_api_5xx" {
  alarm_name          = "order-api-prod-5xx"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 2
  metric_name         = "HTTPCode_Target_5XX_Count"
  namespace           = "AWS/ApplicationELB"
  period              = 60
  statistic           = "Sum"
  threshold           = 5
  treat_missing_data  = "notBreaching"
}

Those four resources are four things every serious service needs: least-privilege IAM, encryption at rest, a dead-letter queue, and a 5xx alarm. None of it is a product decision. The backend engineer has no opinion about deletion_window_in_days or evaluation_periods. But they have to wait three days to get it, and if they write it themselves there is a 90 percent chance they copy it from another service, dragging along assumptions that no longer hold.

3. Trap two: “you build it, you run it” and the cognitive load invoice

The second failure mode has no DevOps team at all. Every engineer handles their own infrastructure. On paper this is “you build it, you run it” in its purest form.

In practice it looks like this:

  • Best practices get invented along the way.
  • Security is somebody else’s problem, except nobody has spare capacity.
  • Naming conventions definitely exist: nah, nah-prod, prod-nah, production-nah, four names for one system.
  • Cost management is disabled because there are still credits to burn.
  • Terraform drift goes unnoticed until an apply deletes a bucket nobody remembers creating.

The core point: most engineers do not want to do operations. They want to build product. But organizations impose it on them anyway, and they respond in one of two ways. They learn just enough to make things run, or the ops work slowly piles onto a few people who then become the “DevOps team” from trap one. The two traps are connected by a straight line.

Cognitive Load Theory applied to production systems

John Sweller introduced Cognitive Load Theory in 1988 and split mental load into three categories:

  1. Intrinsic load: the inherent difficulty of the problem. Building a payment system that cannot double-charge is hard, and it must be hard, because the problem is hard.
  2. Extraneous load: load produced by how the problem is presented, not by the problem itself. Having to remember that your team’s SNS topic lives in a different region than the SQS queue is pure extraneous load. It teaches you nothing about the domain.
  3. Germane load: the load spent building mental models, which is where actual learning happens.

Working memory is bounded, and the bound does not grow with years of experience. Every hour a backend engineer spends remembering that KMS keys need enable_key_rotation, that an ECS task role differs from an EC2 instance role, that the team’s Helm chart requires a podDisruptionBudget, is an hour not spent on germane load. The domain work is not what gets cut. What gets cut is design-level thinking, and the cost only shows up six months later as a migration nobody dares to attempt.

There is a practical way to measure this: count the number of systems an engineer has to log into during a normal week. If that number exceeds the number of product capabilities they own, the organization is paying salary for extraneous load.

What the code looks like when the application knows too much about infra

This is the clearest signal that the boundary is broken. A domain service is quietly solving an infrastructure problem:

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/sqs"
)

// Anti-pattern: application code knows about regions, queue URLs, and the permission request process.
func newQueueClient(ctx context.Context) (*sqs.Client, error) {
	region := os.Getenv("AWS_REGION")
	if region == "" {
		region = "ap-southeast-1" // hard-coded default, nobody remembers why it exists
	}

	cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
	if err != nil {
		return nil, fmt.Errorf("load aws config: %w", err)
	}

	queueURL := os.Getenv("ORDER_QUEUE_URL")
	if queueURL == "" {
		// This error message states the entire organizational problem.
		return nil, fmt.Errorf("ORDER_QUEUE_URL is not set, file a ticket asking DevOps to create the queue")
	}

	return sqs.NewFromConfig(cfg), nil
}

That last comment is an accidental architectural document: your system has a human queue sitting between the application and the infrastructure. No compiler catches that one, and it is the most expensive defect in the file.

4. Conway’s Law: architecture mirrors the organization

In 1967 Melvin Conway wrote a sentence nobody has managed to refute since: a system designed by an organization will mirror that organization’s communication structure.

The practical consequence is direct. If every infrastructure change flows through a ticket queue, your infrastructure will take the shape of that queue: a pile of resources created on discrete requests, with no shared conventions, each one the artifact of a different ticket. Nobody designed it. It grew.

In the other direction, if every team handles everything, the system takes the shape of islands. Each team works its own way, there are no shared standards, and cross-team integration becomes its own project. You call it autonomy, but measure it by time-to-onboard a new engineer and it is entropy.

This is also why the Inverse Conway Maneuver exists: if you want architecture to take a given shape, you change the communication structure first, not the diagram after.

5. Team Topologies: the organizational geometry of a real platform

Matthew Skelton and Manuel Pais offer a more useful model than “Dev versus Ops”. There are four team types:

  • Stream-aligned team: owns an end-to-end business value stream, say the payment flow. This is the team generating revenue.
  • Platform team: provides internal services in self-service form, reducing cognitive load for stream-aligned teams.
  • Enabling team: coaches on a time-boxed basis, helps other teams become capable, then leaves.
  • Complicated-subsystem team: owns subsystems that require deep specialist knowledge, where distributing the work to everyone is pointless. A search engine or an ML runtime.

And three interaction modes: Collaboration (two teams work together, time-boxed), X-as-a-Service (one team consumes another’s service, low communication cost), and Facilitating (one team raises another team’s capability).

Reread the two traps in this vocabulary and the problem becomes obvious:

  • The “DevOps team” pattern is a platform team forced into permanent Collaboration mode with forty teams at once. Collaboration is inherently expensive and only effective in short bursts. Stretching it into a standing operating model means you pay collaboration prices for every trivial infrastructure change.
  • The “you build it, you run it” pattern deletes the platform team and asks every stream-aligned team to do complicated-subsystem work. Nobody has the expertise, and knowledge scatters to the point where no standard survives.
graph LR
  subgraph "Team Topologies in practice"
    SA["`Stream-aligned Team
- Owns the payment flow
- Needs Postgres, queue, IAM
- Should not need VPC knowledge`"]
    PT["`Platform Team
- Owns the golden path
- X-as-a-Service
- Measures adoption, not ticket count`"]
    EN["`Enabling Team
- 6-8 weeks of coaching
- Then leaves
- Facilitating`"]
    CS["`Complicated-subsystem Team
- Search engine / ML runtime
- Deep expertise that cannot be spread thin`"]
  end

  SA -->|"X-as-a-Service"| PT
  SA <-->|"Time-boxed Collaboration"| CS
  EN -.->|"Facilitating"| SA
  EN -.->|"Facilitating"| PT

  style PT fill:#1e1e2e,stroke:#a6e3a1,stroke-width:2px

The hidden truth in this model: knowledge silos are not a bug. If half the organization copy-pastes the same Terraform module, the expertise exists. It just has not been packaged and distributed. The fix is not making everyone know everything. The fix is packaging that expertise into a service others can consume without relearning it.

6. Platform Engineering: treat the platform as a product

Platform Engineering is not buying a nicer dashboard. The shortest and hardest definition: the platform is a product, and its customers are the company’s own engineers (engineering customers).

Two consequences follow immediately:

  1. If customers do not use it, the platform fails. A ticket queue has no customers, only a line. A platform without adoption is an expensive project with no users.
  2. Customers are entitled to demand things. If the golden path is slower than doing it by hand, nobody uses it. Platform quality is measured by engineer experience, not by the number of resources under management.

Golden Path: guardrails built into the road

A golden path is not a wall. It is the fastest and safest route, with guardrails in the right places. The critical part: guardrails belong inside the path, not at an approval gate.

If you expose a simple form or a YAML file and call it self-service, you are not enabling self-service, you are enabling shadow IT. The guardrails have to come with it: least-privilege IAM, KMS rotation enabled, default alarms, logging that cannot be disabled. The user declares intent. The platform handles implementation detail.

Here is what a platform contract looks like:

apiVersion: platform.company.io/v1
kind: Service
metadata:
  name: order-api
  team: payments
spec:
  runtime:
    image: registry.internal/order-api:1.42.0
    port: 8080
    replicas: 3
  capabilities:
    - postgres:
        size: small
        backup: daily
        pitr: true
    - cache: redis
    - queue:
        topic: orders.created
        dlq: true
    - objectStore:
        bucket: order-receipts
        retention: 90d
  env:
    LOG_LEVEL: info
    FEATURE_PARTIAL_REFUND: "true"
  observability:
    slo:
      availability: 99.9
      latencyP99Ms: 300

Those twenty lines replace hundreds of lines of HCL, and more importantly they contain no decision the backend engineer lacks the authority to make. No evaluation_periods. No deletion_window_in_days. Those are owned by the platform, along with the responsibility to update them when best practices change.

Behind it sits a reconciler. Written in Go, it takes the contract and renders infrastructure:

package platform

import (
	"context"
	"fmt"

	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"sigs.k8s.io/yaml"
)

// ServiceSpec is the entire surface a product team has to know.
type ServiceSpec struct {
	APIVersion string `yaml:"apiVersion"`
	Kind       string `yaml:"kind"`
	Metadata   struct {
		Name string `yaml:"name"`
		Team string `yaml:"team"`
	} `yaml:"metadata"`
	Spec struct {
		Runtime struct {
			Image    string `yaml:"image"`
			Port     int    `yaml:"port"`
			Replicas int    `yaml:"replicas"`
		} `yaml:"runtime"`
		Capabilities []map[string]any `yaml:"capabilities"`
		Observability struct {
			SLO struct {
				Availability float64 `yaml:"availability"`
				LatencyP99Ms int     `yaml:"latencyP99Ms"`
			} `yaml:"slo"`
		} `yaml:"observability"`
	} `yaml:"spec"`
}

// Reconcile translates intent into infrastructure. Guardrails live here, not at an approval gate.
func (r *Reconciler) Reconcile(ctx context.Context, raw []byte) error {
	var svc ServiceSpec
	if err := yaml.Unmarshal(raw, &svc); err != nil {
		return fmt.Errorf("parse service contract: %w", err)
	}

	if svc.Spec.Runtime.Replicas < 2 && svc.Spec.Observability.SLO.Availability >= 99.9 {
		return fmt.Errorf(
			"service %s declares an SLO of %.1f%% but runs %d replica: unreachable by construction",
			svc.Metadata.Name,
			svc.Spec.Observability.SLO.Availability,
			svc.Spec.Runtime.Replicas,
		)
	}

	// Default guardrails the engineer never has to declare:
	// 1. Least-privilege IAM derived from the capabilities list.
	// 2. Per-namespace KMS key with rotation enabled.
	// 3. Alarms driven by the declared SLO, with runbook links.
	// 4. Dedicated namespace plus default-deny NetworkPolicy.
	objs, err := r.render(ctx, &svc)
	if err != nil {
		return err
	}

	return r.apply(ctx, objs)
}

The replica-versus-SLO check is worth pausing on. It is what a real guardrail looks like: not a human approval gate, but validation inside the reconcile loop, failing at pull request time. The engineer gets feedback in 30 seconds instead of three days, and the reason is a technical one rather than “that is how the process works”.

graph TB
  subgraph "Platform Engineering (Self-service Golden Path)"
    Eng["`Product Engineer
- Declares intent in service.yaml
- No VPC or KMS knowledge`"]
    Contract["`service.yaml
- 20-30 lines
- Committed alongside application code`"]
    API["`Platform API
- Schema validation
- Policy as code (OPA)
- Feedback in 30 seconds`"]
    Rec["`Reconciler
- Renders Terraform / K8s manifests
- Emits IAM, KMS, default alarms
- Provisions ephemeral envs per PR`"]
    Runtime["`Runtime
- Dedicated namespace
- Default-deny NetworkPolicy
- Ephemeral environment`"]
    Obs["`Observability by default
- Metrics, logs, traces
- SLO alarms with runbooks`"]
  end

  Eng --> Contract
  Contract -->|"git push"| API
  API -->|"Valid"| Rec
  API -.->|"Fails at PR time"| Eng
  Rec --> Runtime
  Rec --> Obs
  Obs -->|"Alerts with a runbook, not a random human"| Eng

  style Rec fill:#1e1e2e,stroke:#a6e3a1,stroke-width:2px

Compare the two diagrams. The first has a human queue between the engineer and the infrastructure, and every wait is a place where knowledge leaks out of the system. The second has a validator between the engineer and the infrastructure, and every failure is immediate feedback. The difference is not tooling. It is who owns specialist knowledge and how that knowledge gets distributed.

7. Platform Engineering is not free

If you have read this far and concluded “build an internal developer platform”, there are traps worth knowing first, because plenty of companies have burned 18 months on a platform nobody uses.

Trap one: rebuilding a closed PaaS. If your platform supports exactly one way to run a workload, and the way is Kubernetes, you are not doing platform engineering, you built a worse PaaS than the ones you can buy. Real workloads are diverse: containers, serverless, VMs, batch jobs, cron, GPU workers. An abstraction over Kubernetes alone is not enough.

Trap two: abstraction without guardrails. Covered above but worth repeating: a pretty web form on top of an AWS API is not safe self-service. Without guardrails, the first security incident ends the entire self-service effort, because the CISO shuts the door and everything reverts to tickets.

Trap three: not measuring adoption. A platform team must track how many teams use the golden path, time from commit to production, and the number of granted exceptions. If the golden path gets bypassed, that is a product signal, not a discipline signal. The four DORA metrics remain the best available yardstick: deployment frequency, lead time for changes, change failure rate, time to restore service.

Trap four: expecting the old DevOps team to become the platform team. This is the most painful one. A team drowning in tickets has neither the capacity nor the time to design APIs, write documentation, and work with internal users like a product team. Platform engineering needs people with software development experience on both sides, and it needs time. It is a startup inside your company.

One detail that gets overlooked: ephemeral environments. If opening a pull request cannot provision a temporary bucket, queue, and database for that PR, your review environment is not a production approximation, and integration bugs will sit in wait until a real deploy. The ability to spin up throwaway environments for both the application and its infrastructure dependencies is one of the clearest markers separating a real platform from a decorative YAML layer.

8. Where to start

There is no universal roadmap, but a few principles have held up.

Start with the most painful repetitive work. Not the flashiest. If every new service needs the same IAM, KMS, and alarms and takes three days to get them, fix exactly that, and turn it into one line of declaration.

Migrate a secondary service first. Not the payment service. Move fast, collect feedback, fix, then expand. Building the platform and only then looking for users is the most reliable way to fail.

Measure before you optimize. Count infrastructure tickets per week, median time for a new service to reach production, and how many systems each engineer has to log into. Those three numbers are enough to tell whether the platform is working.

Keep the platform open. Users need an exit if the golden path does not fit, and that exit has to be cheap. A platform nobody can leave is a platform everybody routes around.

Closing: all infrastructure eventually becomes a platform

There is one line in the original piece worth treating as the thesis: all infrastructure eventually becomes a platform. The only real question is how easy yours is to change.

No organization escapes this. A ticket queue is a platform too, just one with a bad API and response times measured in days. Copy-pasted Terraform is a platform too, just one with no versioning, no owner, and nobody willing to touch it.

DevOps did not die because it was wrong. It died because the meaning got twisted: a set of principles about tearing down walls was turned into a job title, the job title turned into a queue, and the queue turned into an excuse not to think about design. Platform Engineering is not a new name for the same work. It is applying a product mindset to the layer we all quietly agreed was a commodity, and packaging that layer so domain engineers can use it without relearning cloud computing from scratch.

If you are waiting three days for an IAM role, that is not a process problem. It is an organizational architecture problem, and it is fixable.

Share :

Related Posts

Agent = Model + Harness: Do not put an F1 engine into a brakeless bus

Agent = Model + Harness: Do not put an F1 engine into a brakeless bus

At the end of last year, I created a bot that ran in the background to automatically read menus from partner restaurants sent in PDF/Excel format, then parsed them into a Go struct to load into the Menu Service database.

Read More
Lies We Tell Ourselves to Keep Using Golang

Lies We Tell Ourselves to Keep Using Golang

In the backend engineering community, Golang (Go) is frequently praised for its radical simplicity. From microservices at Google, Uber, and Grab to core infrastructure projects like Docker, Kubernetes, and Terraform, Go seems to be everywhere. Engineers routinely swap familiar praises: “Go is dead simple to learn,” “Concurrency in Go is practically free thanks to Goroutines,” and “The Go toolchain compiles instantly into a single self-contained binary.”

Read More
Graceful Shutdown: Benefits and Reasons to Have It

Graceful Shutdown: Benefits and Reasons to Have It

There was a time when our team deployed a new version at 11 pm. After running kubectl rollout restart, 30 seconds later, PagerDuty alerted: 200 502 errors in 5 seconds. Customers were placing orders when they encountered a timeout. We quickly rolled back, then checked the logs - it turned out that the old pod was terminated with SIGTERM, the HTTP server shut down immediately, and 50 requests being processed were cut off entirely.

Read More