background-shape
Workload Specifications with Score, Decoupling Code from Deploy
January 24, 2024 · 7 min read · by Muhammad Amal programming

TL;DR — Score is a small, opinionated workload spec that decouples the developer’s contract from the deployment target. Devs write score.yaml; the platform team owns the translation to Kubernetes, ECS, Nomad, whatever. The abstraction works. The leaks are real but manageable.

We’ve spent a few posts inside the Kubernetes-shaped world — Backstage, Crossplane vs Terraform, the catalog. One question keeps coming up at the orgs I consult with: “What if we want to move off Kubernetes for some workloads? Or what if we have a hybrid Kubernetes-plus-ECS reality?” The honest answer used to be: rewrite a lot of YAML.

Score is the 2024 answer that’s started to feel real. The CNCF accepted it as a sandbox project in late 2023 and the v0.x line has stabilized enough to use in production for new workloads. This post is the what-and-when.

The shape of the problem

Most platform teams I’ve worked with have multiple deployment runtimes whether they admit it or not. Production is usually Kubernetes. Some batch workloads run on the cloud’s managed batch service. Some legacy stacks are still on ECS or VMs. The data team has a workflow engine that’s its own thing.

The developer experience is fragmented in lockstep. A backend dev writing a service gets Helm charts. A data dev gets Airflow DAGs wrapped in their own templating. A frontend dev gets a different pipeline that ends in CDN configs. None of these workflows know about each other. None of them are portable.

What you want is a single workload description format that’s:

  • Small enough for developers to write
  • Opinionated about what a workload needs (image, ports, env, resources, dependencies)
  • Agnostic about where the workload runs

That’s what Score is. The spec is roughly 40 lines of YAML for a typical service.

What Score looks like

# score.yaml — Score v1b1, January 2024
apiVersion: score.dev/v1b1
metadata:
  name: orders-api
containers:
  orders-api:
    image: ghcr.io/acme/orders-api:v0.4.2
    variables:
      DATABASE_URL: ${resources.db.uri}
      LOG_LEVEL: info
    resources:
      limits:
        memory: 512Mi
        cpu: 500m
      requests:
        memory: 256Mi
        cpu: 100m
    livenessProbe:
      httpGet:
        path: /healthz
        port: 8080
service:
  ports:
    http:
      port: 80
      targetPort: 8080
resources:
  db:
    type: postgres
    params:
      engine: postgres
      version: "16"
  dns:
    type: dns
    params:
      domain: orders.api.acme.com

A developer can write this in five minutes. They never see a Kubernetes manifest. They never see a Terraform file. They declare what their workload needs: an image, env vars, ports, a Postgres database, a DNS entry. Done.

The translation to a runtime is the platform team’s problem. That separation is the whole point.

How translation works

Score itself is a spec plus a set of generators. Two are stable enough for production work:

  • score-compose — turns score.yaml into a docker-compose.yml. Useful for local development.
  • score-helm — turns score.yaml into a Helm chart. Useful for Kubernetes targets.

There are early-stage generators for ECS, Nomad, and Humanitec-backed runtimes. Humanitec is the company behind Score and offers a hosted platform that does the translation in their control plane.

# Local dev — same score.yaml that runs in prod
score-compose run -f score.yaml > compose.yaml
docker compose -f compose.yaml up

# Kubernetes — platform team controls the values file
score-helm run -f score.yaml --values platform-overrides.yaml \
  | helm template - | kubectl apply -f -

The --values platform-overrides.yaml is where the platform team sneaks in everything the developer doesn’t need to know about: pod security context, service account name, monitoring sidecars, ingress class, image pull secrets. The dev’s score.yaml stays clean.

The resource provisioner problem

The most interesting and contentious part of Score is the resources block.

When a developer writes:

resources:
  db:
    type: postgres

…they’re asking the platform for a Postgres database. They don’t care how it’s provisioned. The platform team decides.

In a Kubernetes-only world, the platform team configures the score-helm provisioner to create a Crossplane Database CR which under the hood provisions an RDS instance. In a Compose-on-laptop world, the provisioner spins up a Postgres container. Same score.yaml, different backings.

This is genuinely powerful. It’s also where the abstraction is leakiest in 2024.

Production-grade provisioners exist for the common cases (Postgres, Redis, S3-style buckets) on AWS via Humanitec’s Resource Definitions or via custom Crossplane Compositions. For anything more exotic — vector databases, message queues with specific guarantees, regional caches — you’re rolling your own.

That’s not necessarily bad. The standard cases are 80% of workloads. The trick is being honest with developers about which resources.type values are supported and which would require a platform conversation.

When Score helps vs. when it doesn’t

The clearer use case: you’re standing up a new platform and you want to keep options open about the runtime.

If you commit hard to Kubernetes via Score from day one, you get:

  • Local dev that matches prod via score-compose
  • A clean dev-facing contract that doesn’t leak Kubernetes specifics
  • The optionality to add ECS or Nomad later without changing developer workflows

If you’re locked into Kubernetes forever and have no plans to change, Score is a useful indirection but not transformative. You could get most of the benefit with a custom Helm chart abstraction.

Where Score doesn’t help yet:

  • Stateful workloads with complex storage requirements. The volume semantics are still maturing. You’ll likely drop to raw manifests for these.
  • Highly Kubernetes-specific features. Pod disruption budgets, custom resource limits, exotic affinity rules — Score covers the common cases but not the corner ones.
  • Existing workloads on legacy Helm charts. Migration is non-trivial and Score doesn’t ingest Helm charts; you write score.yaml from scratch.

Don’t try to retrofit every existing service into Score. Use it for new services and a small number of motivated migrations.

Wiring Score into Backstage

The integration is straightforward in 2024. The Backstage software template generates a score.yaml instead of a Helm chart. CI runs the platform-controlled translation. Argo CD picks up the output and syncs to the cluster.

# A Backstage template step that generates score.yaml
- id: scaffold-score
  action: fetch:template
  input:
    url: ./skeleton-score
    values:
      name: ${{ parameters.name }}
      image_repo: ghcr.io/acme/${{ parameters.name }}
      domain: ${{ parameters.name }}.api.acme.com
- id: ci-and-gitops
  action: fetch:template
  input:
    url: ./skeleton-ci
    values:
      name: ${{ parameters.name }}

The skeleton produces a repo with score.yaml, a Dockerfile, and a CI workflow that does score-helm run -f score.yaml --values <platform-overrides> | helm template - and commits the result to a GitOps repo. Argo CD takes it from there.

From the developer’s view: they own score.yaml and the source code. Everything else is platform-team-owned, including the platform-overrides.yaml that controls how score.yaml becomes real Kubernetes objects.

Common Pitfalls

  • Treating score.yaml as fully portable. It’s portable enough to be useful but not magic. If your dev environment is Compose and prod is Kubernetes, some behaviors (e.g., service discovery, secret injection) will differ. Document those gaps.
  • Letting developers customize the platform overrides. The whole point is that they don’t. If a dev needs something the overrides don’t allow, it’s a platform conversation, not a YAML edit.
  • Resource types without a real provisioner. Don’t advertise resources.type: vector-db until you have a working provisioner. A dev who hits “no provisioner for this type” mid-feature is a bad day.
  • Pinning to an unstable Score version. v1b1 is still beta. Track the changelog. Don’t auto-update generators in CI without a controlled rollout.
  • Skipping the local-dev story. Score’s killer feature is “the same spec runs on your laptop and in prod.” If you don’t wire score-compose, you’ve shipped half of it.

The pitfall I personally underestimated: I assumed devs would love the simplification. Some did. Others felt the loss of control — “I can’t tune my pod spec anymore” — even though they hadn’t been tuning it. Sell the move, don’t impose it.

Wrapping Up

Score is a useful abstraction in the right place. For new platforms in 2024, especially ones that might span multiple runtimes, it’s worth a serious look. For mature Kubernetes-only shops, evaluate it but don’t force migrations. The CNCF status will help adoption over the next year. The provisioner ecosystem is where to watch — that’s where the spec earns or loses its keep.

For deeper reading, the Score documentation and Humanitec’s reference architectures are the best starting point. Last post for the month picks up on Argo CD ApplicationSets — the missing piece in a self-service GitOps story.