← Back to homedevops

Platform Engineering in Practice: An Internal Developer Platform Without a Platform Team

← All writing

The dirty secret of platform engineering is that most of the writing about it assumes you have a platform team. Dedicated headcount, a portal roadmap, quarterly OKRs about "developer experience scores." Most software organisations are not Spotify. They're five to fifty engineers shipping products, and the person "doing platform" is whoever got tired of copy-pasting the deploy workflow last.

We build web products for clients at Luminary, which means we bootstrap new production systems constantly — new repos, new pipelines, new environments, new on-call realities — with a small senior team and zero appetite for infrastructure that needs babysitting. That constraint forced us to figure out what an internal developer platform actually is when nobody's full-time job is building one. The answer turns out to be mostly boring: templates, conventions, a handful of reusable workflows, and the discipline to write things down. This post is the long version of that answer.

Illustration: a calm control room with friendly levers and dashboards

From "DevOps team" to platform engineering: what actually changed

The original sin of the last decade was hiring a "DevOps team." DevOps was supposed to dissolve the wall between development and operations; instead, many orgs renamed the ops team, handed them Jenkins and Terraform, and rebuilt the wall with newer bricks. Developers still filed tickets to get an environment. The ops-turned-DevOps team still got paged for services they didn't write. Nothing structural changed except the job titles.

Platform engineering, when it's done honestly, is a different contract. The insight is this: the platform is a product, and developers are its customers. Instead of a team that does infrastructure work for developers (a service desk), you build a thing developers use themselves (a product). The unit of delivery stops being "a completed ticket" and becomes "a capability developers can self-serve."

That reframing changes what you build:

  • A DevOps team provisions your staging environment. A platform gives you a command, a pull request label, or a template that provisions it.
  • A DevOps team reviews your Dockerfile. A platform ships a base image and a build workflow where the Dockerfile is already correct.
  • A DevOps team knows how deploys work. A platform makes deploys work the same way everywhere, so the knowledge is ambient.

Notice that nothing in that list requires a team. It requires product thinking applied to internal tooling, and product thinking scales down beautifully. A single senior engineer spending 10% of their time curating templates and workflows is doing platform engineering. Many staffed platform teams, meanwhile, aren't — they're doing ticket-ops with a trendier name. More on that failure mode at the end.

Golden paths, not golden cages

The core artefact of platform engineering is the golden path (Spotify's term) or paved road (Netflix's): a supported, well-documented, low-friction way to do a common thing. Start a service. Add a queue. Get a database. Ship to production.

Two properties make a path golden rather than merely mandatory:

  1. It's the easiest option, not the only option. Developers take the paved road because it's genuinely faster, not because the platform team blocks the dirt roads. If someone has a real reason to deviate — a client requirement, a workload that doesn't fit — they can, and they own the consequences. The moment deviation requires permission, you've built a cage, and smart engineers will spend their creativity escaping it instead of shipping.

  2. It encodes decisions, not just tools. A golden path isn't "we use Terraform." It's "a new service gets a Postgres database by adding this module block, the credentials land in this secret store, the connection string arrives as DATABASE_URL, and backups are on by default with a 30-day retention you can override." The value is in the decisions you no longer have to make.

At small scale, your golden paths are countable on one hand. Ours are roughly: start a new product repo, add CI/CD, provision cloud resources, get a preview environment, respond to an incident. That's it. Resist the urge to pave roads nobody drives on.

What an IDP means at 5–50 engineers

Strip away the vendor decks and an internal developer platform is five capabilities. At small scale each one has a deliberately modest implementation:

CapabilityBig-org version5–50 engineer version
Service scaffoldingPortal with software templatesGitHub template repos + a create-* script
CI/CDCustom pipeline platformReusable GitHub Actions workflows, called by every repo
EnvironmentsEphemeral env orchestratorPaaS preview deployments + one shared staging
SecretsVault with dynamic credentialsCloud secret manager + OIDC, zero long-lived keys in CI
ObservabilityIn-house o11y pipelineVendor defaults baked into the template: structured logs, traces, one dashboard, three alerts

The pattern across all five: push the undifferentiated heavy lifting onto managed services, and spend your scarce attention on the glue and the defaults. The glue is where your org's decisions live. The defaults are what make the golden path golden.

Scaffolding: the template repo is your portal

A well-maintained template repository does most of what a service catalog does at this scale. Ours look like this:

template-web-service/
├── .github/
│   ├── workflows/
│   │   ├── ci.yml            # calls the shared reusable workflow
│   │   └── deploy.yml        # calls the shared deploy workflow
│   ├── CODEOWNERS
│   └── dependabot.yml
├── infra/
│   ├── main.tf               # module calls only — no raw resources
│   ├── variables.tf
│   └── environments/
│       ├── staging.tfvars
│       └── production.tfvars
├── src/
├── .env.example              # every env var, documented, no values
├── Dockerfile                # multi-stage, non-root, pinned base image
├── RUNBOOK.md                # deploy, rollback, escalate — see below
└── README.md                 # what this is, how to run it locally

Every file in the template is a decision made once: the Dockerfile runs as non-root, Dependabot targets the integration branch, CODEOWNERS makes review routing work on day one. New projects get every improvement for free, and a quarterly hour of diffing old repos against the template keeps the fleet from drifting.

CI/CD: one workflow, called everywhere

Copy-pasted CI is where conventions go to die. Every repo's pipeline mutates independently until no two deploys behave alike. The fix is reusable workflows: the logic lives in one repository, and every project calls it with a few inputs.

# .github/workflows/ci.yml — in every product repo
name: CI
on:
  pull_request:
    branches: [dev, prod]

jobs:
  checks:
    uses: luminary/platform-workflows/.github/workflows/node-ci.yml@v3
    with:
      node-version: "22"
      run-e2e: false
    secrets: inherit

And the shared side:

# platform-workflows/.github/workflows/node-ci.yml
name: Node CI
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: "22"
      run-e2e:
        type: boolean
        default: false

jobs:
  lint-build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run build
      - run: npm test --if-present

  e2e:
    if: inputs.run-e2e
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e

Version the shared workflows with tags (@v3) so a change to the platform doesn't ripple into every repo at once, and cut a changelog entry when you bump a major. This is the cheapest form of platform-as-product discipline: your consumers upgrade deliberately, like they would any dependency.

Two conventions matter more than any YAML: every repo deploys the same way (merge to the integration branch → auto-deploy to staging; promote via PR → production), and CI is a merge gate, not a deploy mechanism — let the platform (PaaS Git integration, or a single deploy workflow) own the actual deployment so there's exactly one code path that touches production.

Environments: previews are the killer feature

If you adopt one platform capability this year, make it preview environments — a full deployed copy of the app per pull request. They collapse the feedback loop from "works on my machine" to "click this URL." Reviewers review the running thing. Clients see the change before merge. QA happens where the bugs actually live.

On a managed PaaS (Vercel, Netlify, Fly.io, Render, Cloud Run with a small wrapper) this is a checkbox, which is exactly the point. Building ephemeral environments yourself on Kubernetes is a genuinely hard orchestration problem — namespace-per-PR, seeded data, DNS, teardown — and it's the single most common way small teams accidentally sign up for a platform team they didn't budget for. Buy this one.

The detail that separates useful previews from decorative ones is data. A preview against an empty database tells you the app boots. A preview against a seeded, representative dataset tells you the feature works. Invest in a good seed script; it pays for itself weekly.

Secrets: boring, centralized, short-lived

The small-team golden path for secrets has three rules:

  1. Secrets live in exactly one place per environment (your cloud's secret manager, or the PaaS's env var store) and are injected at deploy time. No secrets in repos, CI variables duplicated per-repo, or Slack DMs.
  2. CI authenticates to the cloud with OIDC federation, not long-lived access keys. GitHub Actions can exchange its identity token for short-lived cloud credentials on AWS, GCP, and Azure; there is no good reason to store a cloud key in CI anymore.
  3. .env.example in every repo documents every variable the app reads, with a comment, with no values. Missing config should fail loudly and fail closed — a route that returns an honest 503 when its API key is absent beats one that half-works.

Observability: defaults in the template, not a project later

Observability retrofits never happen. The only way small teams get it is if the template already emits structured JSON logs with a request ID, ships traces to whatever vendor you've standardized on, and comes with one dashboard and three alerts: error rate, p95 latency, and "the thing is down." That's not a mature observability practice — it's the floor that makes Tuesday's incident debuggable instead of archaeological.

Build vs adopt: Backstage, lighter portals, or a README

At some point someone will suggest Backstage. Backstage (open-sourced by Spotify, now a CNCF project) is a real and capable piece of software — and it is also a full React/Node application that you deploy, upgrade, secure, and write plugins for. Teams that thrive with it treat it as a product with dedicated owners. Below roughly 50 engineers, Backstage usually costs more attention than the problems it solves, because its core value — a catalog that makes hundreds of services and dozens of teams discoverable — addresses a problem you don't have. You can hold fifteen repos in your head.

The honest decision table:

You have…Reach for…
5–15 engineers, <20 reposConventions + template repos + a PLATFORM.md index. A portal is overhead.
15–50 engineers, growing service countA lightweight catalog/portal (hosted options like Port or Cortex, or a generated static index) if discoverability is actually hurting.
50+ engineers, multiple teams, real discoverability painNow Backstage-class tooling earns its keep — with named owners.

The trap is adopting the artefact before the problem. A portal that lists eight services is a screenshot for the board deck, not a platform. Meanwhile a PLATFORM.md at the top of your org — what services exist, where they deploy, who owns them, links to the runbooks — delivers most of the value of a catalog at this scale and costs an afternoon.

The 80% kit: five things, none of them a platform

If we were bootstrapping a platform for a 20-engineer org tomorrow, this is the whole initial scope:

  1. Template repos — one per stack you actually use (probably two, not seven), maintained like the products they spawn.
  2. Reusable CI workflows — versioned, changelogged, called by every repo. One CI path, one deploy path.
  3. IaC modules — a small private module set wrapping your cloud's primitives with your decisions baked in. A service's main.tf should read like a bill of materials:
module "app" {
  source  = "app.terraform.io/luminary/web-service/cloudrun"
  version = "~> 2.4"

  name        = "billing-api"
  environment = var.environment

  # Decisions the module makes for you:
  # min instances, structured log sink, alert policies,
  # secret bindings, non-root service account with least privilege.

  database = {
    tier              = "small"
    backup_retention  = 30
  }
}

Product engineers compose modules; only the modules contain raw resources. Reviewing infra PRs becomes reviewing intent.

  1. Preview environments — bought from a PaaS, per the previous section, with a real seed script.
  2. A runbook per service — one page: how it deploys, how to roll back, where the logs and dashboard live, the three most likely failures and their fixes, who to wake up. Written before the first incident, updated after every incident. An out-of-date runbook is worse than none, so keep it short enough that updating it is a two-minute diff in the incident's PR.

That list is deliberately anticlimactic. It's also, in our experience, the difference between onboarding a new project in a day versus a week, and between incidents that take twenty minutes and incidents that take a weekend.

Metrics without the cargo cult

The DORA research program gave the industry four useful keys: deployment frequency, lead time for changes, change failure rate, and time to restore service. They're good metrics because they measure the system, not the individuals, and because they resist gaming better than most — you can't juice deployment frequency without actually making deploys cheaper and safer.

The cargo cult version is a dashboard nobody acts on, or worse, DORA-as-performance-review. Three rules keep it honest at small scale:

  • Measure to find friction, not to grade people. The question is "where does a change wait?" — in review, in a manual QA queue, behind a release train — not "which engineer deploys most."
  • Lead time is the one to watch. Time from commit to production running compresses only when the whole path is healthy: small PRs, fast CI, safe deploys, no ceremony. If lead time is hours, most other problems are survivable. If it's weeks, no portal will save you.
  • You don't need a metrics product. Deployment frequency and lead time fall out of your Git and deploy history with a small script. Change failure rate and restore time fall out of a lightweight incident log — a shared doc with a timestamp per incident is enough at this size. Start there; buy tooling when the script becomes the bottleneck, which it may never do.

And measure the qualitative thing the DORA keys miss: ask engineers, quarterly, "what's the most annoying part of shipping here?" The answers — usually a flaky test, a slow build, confusing staging data — are your platform roadmap, and fixing them buys more goodwill than any portal launch.

Kubernetes is an answer, not the answer

Kubernetes is the correct substrate when you have the problems it solves: many heterogeneous services, workloads needing fine-grained placement or custom networking, multi-cloud or on-prem constraints, or an org big enough to amortise the cluster's fixed operational cost. It's a superb piece of infrastructure and a terrible default.

For a small team, self-managed Kubernetes converts product engineering time into cluster upkeep — upgrades, node pools, ingress controllers, cert rotation, autoscaler tuning, the works. Even managed Kubernetes (EKS, GKE, AKS) hands you a powerful engine and leaves you to build the car: deploy tooling, secrets flow, preview environments, observability wiring. That car is precisely the platform you were trying not to build.

Meanwhile, a managed PaaS is an internal developer platform you rent. Vercel, Fly.io, Render, Railway, Cloud Run: Git-triggered deploys, preview environments, TLS, log streaming, rollbacks, scale-to-zero — the golden path is the product. Our stack at Luminary leans exactly this way: PaaS-deployed apps, PR-only branch protection with CI as the merge gate, branch-pinned preview and production domains, and managed services for anything stateful. The platform work left over for us is the glue described in this post, and it fits in the margins of client work.

The pragmatic sequence is: PaaS until it hurts. The hurts are legible when they arrive — a workload the PaaS can't run (long-lived connections, GPUs, unusual runtimes), egress or compute bills that clearly exceed what self-managing would cost including labour, or compliance requirements about where and how things run. Until one of those is concretely true, Kubernetes is a solution in search of your problem. And when one is true, move that workload — not everything.

Anti-patterns: how platforms rot

The platform as gatekeeper. Every deviation from the golden path requires platform-team approval; the platform's roadmap becomes a list of things developers are prevented from doing. This inverts the entire premise. A platform earns adoption by being better, not by being mandatory. The tell: engineers maintaining shadow infrastructure — a personal cloud account, a side-channel deploy — because the sanctioned path is slower than going around it.

Ticket-ops with a rebrand. The "platform team" is a queue: file a ticket for an environment, a DNS record, a secret. Throughput is capped by the team's headcount, developers wait, and the platform engineers spend their days on toil instead of paving roads. The test is brutal and simple: can a developer get the thing without a human in the loop? If the answer is no, you have a service desk, whatever the org chart says. Self-service is the defining property of a platform — not the portal, not the tech stack, not the team name.

The platform nobody asked for. Six months building a portal/operator/CLI, launched to indifference, because it solved the platform-builder's idea of the problem. Prevention is the product discipline again: pave roads people already walk. If everyone copies the same workflow file, that's your first platform feature. If nobody has asked for a service catalog, don't build one.

Snowflake accretion. No gatekeeping doesn't mean no gardening. Deviations that prove to be improvements flow back into the template; deviations that prove to be mistakes get paved over. A platform that's never weeded becomes a museum of past decisions.

Takeaways

  • Platform engineering's real shift is treating internal tooling as a product with developer-customers — and product thinking scales down to one engineer at 10% time. You don't need a platform team to have a platform.
  • Golden paths must be the easiest option, not the only one. Encode decisions, not just tool choices, and only pave roads people actually walk.
  • At 5–50 engineers, the IDP is five things: template repos, versioned reusable CI workflows, a small set of opinionated IaC modules, bought preview environments, and a one-page runbook per service.
  • Skip Backstage until discoverability genuinely hurts (usually 50+ engineers). A PLATFORM.md index is the small-team service catalog.
  • Use DORA metrics to find where changes wait, not to grade engineers. Lead time for changes is the single most informative number, and a script over Git history is enough to compute it.
  • Default to a managed PaaS — it's an IDP you rent, previews included. Adopt Kubernetes when a specific workload or bill proves the need, and move only that workload.
  • Watch for the two rot patterns: platform-as-gatekeeper and ticket-ops. The test for both is the same — can a developer self-serve without a human in the loop?
  • Secrets: one store per environment, OIDC instead of long-lived CI keys, and an exhaustive .env.example. Observability: baked into the template, because retrofits never happen.

Enjoyed the read? We build this stuff for clients too.

Start a project