background-shape
Score Spec for Workload Portability in 2025
October 20, 2025 · 10 min read · by Muhammad Amal programming

TL;DR — Score Spec is the workload definition that finally got the abstraction right. One score.yaml describes what your app needs (a container, a database, a DNS name). Different implementations (score-compose, score-k8s, Humanitec) translate that into a target environment. October 2025 is the right time to start using it because the tooling stabilized in 0.5.

For years the answer to “how do I write one definition of my workload that runs on my laptop and in production” was “you don’t, you maintain a docker-compose.yml for local dev and a Helm chart for prod and you pray they don’t drift”. Score Spec is the first serious attempt at a portable workload definition that I’ve actually seen pay back. It’s not a deployment system. It’s an input format that deployment systems consume.

This post walks through Score from the perspective of a backend engineer adding it to an existing service. I’ll cover the spec itself, the local workflow with score-compose, the production workflow with score-k8s, and where Humanitec fits as a managed implementation. The examples are October 2025 versions, so the CLIs and field names are current.

For context on how Score fits into a Backstage-led platform, the earlier posts in this series on Backstage 1.34 and golden-path templates frame the broader picture. Score is the workload contract that a golden-path template should emit.

1. The Score Spec File

A Score workload is a YAML file, conventionally score.yaml, that describes what the app is and what it needs. It deliberately omits how to run it. Here’s a complete example:

apiVersion: score.dev/v1b1
metadata:
  name: payments-api

containers:
  api:
    image: ghcr.io/acme-engineering/payments-api:1.4.2
    variables:
      LOG_LEVEL: info
      DATABASE_URL: ${resources.db.url}
      QUEUE_URL: ${resources.queue.url}
    resources:
      requests:
        cpu: "200m"
        memory: "256Mi"
      limits:
        cpu: "1000m"
        memory: "512Mi"

service:
  ports:
    http:
      port: 80
      targetPort: 8080

resources:
  db:
    type: postgres
    params:
      version: "16"
  queue:
    type: sqs
    params:
      visibility_timeout: 30
  route:
    type: dns
    params:
      hostname: payments.acme.com

Three things stand out. First, there’s no Kubernetes-specific anything. No Deployment, no Service, no Ingress. Second, the resources section is declarative needs, not implementations. Saying type: postgres doesn’t pin a cloud provider, a managed service, or a local container. Third, the ${resources.db.url} interpolation gets resolved by the implementation at provisioning time. The app never sees the templating.

This is the whole point. The Score file is a contract that the developer owns. The implementation (compose, k8s, Humanitec) translates it into the deployment artifacts for a specific environment. Different environments can resolve type: postgres differently. Local might be a postgres:16 container. Staging might be a shared RDS instance. Prod might be a dedicated Aurora cluster.

2. Local Development with score-compose

score-compose translates a Score file into a docker-compose.yaml that you can run locally. Install it:

curl -fsSL https://github.com/score-spec/score-compose/releases/download/v0.22.0/score-compose_0.22.0_darwin_arm64.tar.gz \
  | tar xz -C /usr/local/bin score-compose
score-compose --version

Initialize a workspace and generate compose output:

mkdir payments-api-local && cd payments-api-local
score-compose init
score-compose generate /path/to/score.yaml --output compose.yaml

The first time you run init, it creates a .score-compose/ directory with a state file and a set of resource provisioners. Provisioners are how score-compose knows what to do when it sees type: postgres. The default provisioners cover postgres, redis, mysql, dns, and route. Look in .score-compose/00-default.provisioners.yaml to see them.

For our example, the generated compose.yaml looks something like:

services:
  payments-api-api:
    image: ghcr.io/acme-engineering/payments-api:1.4.2
    environment:
      LOG_LEVEL: info
      DATABASE_URL: postgres://postgres:postgres@db-payments-api:5432/payments-api
      QUEUE_URL: http://localstack:4566/000000000000/payments-api-queue
    ports:
      - target: 8080
    depends_on:
      db-payments-api:
        condition: service_healthy

  db-payments-api:
    image: postgres:16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: payments-api
    healthcheck:
      test: [CMD, pg_isready, -U, postgres]

Run docker compose up and the whole thing comes up. The application’s DATABASE_URL env var points at the local postgres container. The developer didn’t write any docker-compose YAML.

3. Custom Provisioners for Your Org

The default provisioners are generic. In practice every org has opinions: queues come from SQS, databases come from RDS in dev mode (not local containers), DNS gets registered with a wildcard ACM cert. Custom provisioners encode those opinions.

A provisioner is a YAML file that matches by resource type and emits the compose services or environment variables needed:

# .score-compose/10-acme.provisioners.yaml
- uri: template://acme/sqs
  type: sqs
  init: |
    queue_name: {{ .Id | replace "_" "-" }}-{{ .Uid | trunc 8 }}
  state: |
    queue_url: http://localstack:4566/000000000000/{{ .Init.queue_name }}
    queue_arn: arn:aws:sqs:us-east-1:000000000000:{{ .Init.queue_name }}
  shared: |
    services:
      localstack:
        image: localstack/localstack:3.8
        environment:
          SERVICES: sqs
        ports:
          - 4566:4566
  outputs: |
    url: {{ .State.queue_url }}
    arn: {{ .State.queue_arn }}

The provisioner declares a localstack service (in shared, so multiple workloads share one), names the queue based on the workload ID, and exposes url and arn outputs that the Score file’s ${resources.queue.url} interpolation resolves against.

Ship the provisioner via an internal Git repo and reference it in score-compose init:

score-compose init \
  --provisioners https://github.com/acme-engineering/score-provisioners/releases/download/v0.3.0/provisioners.tar.gz

Now every team’s score.yaml that says type: sqs gets the same local setup, with the same naming, the same localstack version, and the same env var shape.

4. Kubernetes with score-k8s

For Kubernetes targets, score-k8s is the analog of score-compose. It generates Kubernetes manifests from the same Score file. Install:

curl -fsSL https://github.com/score-spec/score-k8s/releases/download/v0.4.0/score-k8s_0.4.0_linux_amd64.tar.gz \
  | tar xz -C /usr/local/bin score-k8s

Generate manifests:

score-k8s init
score-k8s generate score.yaml --output manifests.yaml

The output is a multi-document YAML with a Deployment, a Service, and (if you defined a route) an Ingress or Gateway:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments-api
spec:
  replicas: 1
  selector:
    matchLabels: { app.kubernetes.io/name: payments-api }
  template:
    metadata:
      labels: { app.kubernetes.io/name: payments-api }
    spec:
      containers:
        - name: api
          image: ghcr.io/acme-engineering/payments-api:1.4.2
          ports: [{ containerPort: 8080 }]
          env:
            - name: LOG_LEVEL
              value: info
            - name: DATABASE_URL
              valueFrom: { secretKeyRef: { name: payments-api-db, key: url } }
          resources:
            requests: { cpu: 200m, memory: 256Mi }
            limits: { cpu: 1000m, memory: 512Mi }
---
apiVersion: v1
kind: Service
metadata: { name: payments-api }
spec:
  selector: { app.kubernetes.io/name: payments-api }
  ports: [{ port: 80, targetPort: 8080 }]

Note the DATABASE_URL came out as a secret reference, not a plaintext value. That’s because the postgres provisioner for k8s creates a Secret resource (presumably populated by an operator like Crossplane), and score-k8s wires the reference automatically.

The score-k8s provisioners are configured the same way as compose ones, but they emit Kubernetes manifests instead of compose services. The good ones for production use Crossplane 1.18 to create real cloud resources rather than in-cluster containers:

# .score-k8s/10-acme.provisioners.yaml
- uri: template://acme/postgres-rds
  type: postgres
  manifests: |
    - apiVersion: rds.aws.upbound.io/v1beta1
      kind: Instance
      metadata:
        name: {{ .Id }}
      spec:
        forProvider:
          engine: postgres
          engineVersion: {{ .Params.version }}
          instanceClass: db.t4g.small
          allocatedStorage: 20
          dbName: {{ .Id | replace "-" "_" }}
        writeConnectionSecretToRef:
          name: {{ .Id }}-conn
          namespace: {{ .WorkloadNamespace }}

Now type: postgres in production becomes a real RDS instance provisioned by Crossplane, with the connection details landing in a Secret that the Deployment references.

5. Pipeline Integration

The CI pipeline runs score-k8s generate and feeds the output into a GitOps repo:

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  manifest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install score-k8s
        run: |
          curl -fsSL https://github.com/score-spec/score-k8s/releases/download/v0.4.0/score-k8s_0.4.0_linux_amd64.tar.gz \
            | tar xz -C /usr/local/bin score-k8s
      - name: Generate manifests
        run: |
          score-k8s init \
            --provisioners https://github.com/acme-engineering/score-k8s-provisioners/releases/download/v0.5.0/provisioners.tar.gz
          score-k8s generate score.yaml \
            --image ghcr.io/acme-engineering/payments-api:${{ github.sha }} \
            --output build/manifests.yaml
      - name: Push to GitOps repo
        run: |
          git clone https://x-access-token:${{ secrets.GITOPS_PAT }}@github.com/acme-engineering/gitops.git
          cp build/manifests.yaml gitops/apps/payments-api/manifests.yaml
          cd gitops && git add -A && git commit -m "deploy payments-api ${{ github.sha }}" && git push

ArgoCD watches the GitOps repo and applies the manifests to the cluster. Score isn’t in the deploy loop after generation. It’s a build-time tool, not a runtime one.

+--------+                +-----------+              +-----------+
| score  |  score-k8s     | manifests |   ArgoCD     | Kubernetes|
| .yaml  +--------------->+ .yaml     +------------->+  cluster  |
+--------+   generate     +-----------+    sync      +-----------+
   |
   | score-compose
   v
+------------+
| compose    |
| .yaml      |  -> docker compose up (local)
+------------+

6. Humanitec as a Managed Implementation

Humanitec is one of the original Score Spec sponsors and offers a managed implementation. Instead of running score-k8s in your CI, you push the Score file to Humanitec and it orchestrates the deployment, including resource provisioning.

The Humanitec CLI does the push:

humctl score deploy \
  --file score.yaml \
  --image ghcr.io/acme-engineering/payments-api:1.4.2 \
  --app payments-api \
  --env staging

Humanitec resolves the resources based on the environment’s resource graph, which you configure in the Humanitec UI or via API. The same score.yaml deploys to staging with shared RDS, to prod with dedicated Aurora, and to development with in-cluster postgres, all decided by the platform team’s configuration, not by the developer.

The trade-off is vendor lock-in to Humanitec’s control plane. If you’re a small team, the managed path saves you from building the provisioner library yourself. If you’re a large team with dedicated platform engineers, running score-k8s with custom provisioners gives you total control. I’ve seen both work. The wrong move is doing both at once.

7. Integration with Backstage Templates

In a Backstage-led platform, the scaffolder template emits a score.yaml as part of the skeleton. Reference the previous post in this series for the template structure. The relevant skeleton file looks like:

# templates/typescript-service/skeleton/score.yaml
apiVersion: score.dev/v1b1
metadata:
  name: ${{ values.name }}
containers:
  api:
    image: ghcr.io/acme-engineering/${{ values.name }}:latest
    variables:
      LOG_LEVEL: info
{%- if values.needs_database %}
      DATABASE_URL: ${resources.db.url}
{%- endif %}
service:
  ports:
    http:
      port: 80
      targetPort: 8080
{%- if values.needs_database %}
resources:
  db:
    type: postgres
    params:
      version: "16"
{%- endif %}

Templated scaffold, opinionated provisioners, ArgoCD pickup. The developer touches the Score file, not Kubernetes manifests. The platform team owns the provisioners, which is where the org-specific knowledge lives.

Common Pitfalls

  • Treating Score as a deployment tool. It’s not. It’s a definition format. The deployment happens via the implementation (score-k8s, Humanitec, etc.). If you find yourself trying to make Score do conditional logic at deploy time, you’ve misunderstood the boundary.
  • Stuffing platform concerns into the workload file. The developer’s score.yaml should not know what region the app runs in, what IAM role it uses, or what node selector applies. Those belong to the implementation’s environment config, not the workload.
  • Skipping custom provisioners. The default provisioners are demoware. They run a postgres container locally and don’t even try in k8s. Without custom provisioners, Score’s promise of portability isn’t realized because every team writes their own resource glue.
  • Pinning the wrong tool version. The Score spec is at v1b1 (beta) but stabilizing fast. The CLIs (score-compose 0.22, score-k8s 0.4) are still evolving, and field names in provisioners changed between minor versions through 2025. Pin both the spec version and the CLI version in CI.

Troubleshooting

  • Error: unknown resource type 'postgres'. The provisioner library you passed to --provisioners doesn’t include a handler for that type. Look in the generated .score-compose/score.state.yaml to see which provisioners loaded. Usually a typo in the provisioners URL.
  • Generated manifests have empty env var values. The interpolation ${resources.db.url} couldn’t resolve because the provisioner’s outputs didn’t include a url key. Open .score-compose/score.state.yaml and check the resource’s resolved state.
  • score-k8s generate succeeds but pods crashloop on start. The provisioner emitted a Secret reference but the operator that creates the Secret (Crossplane, for instance) hasn’t finished provisioning. Score doesn’t wait for resources to be ready. Add a Helm chart’s Job resource or a Crossplane composition with readiness hooks to handle ordering.

Wrapping Up

Score Spec is the workload definition I wish I’d had five years ago. The October 2025 stack (Score 0.5, score-k8s 0.4, score-compose 0.22) is stable enough to deploy real workloads against. The big gain isn’t a slicker YAML format, it’s the contract between developer and platform team. Devs own the workload definition; platform owns the implementation. That separation finally makes “platform engineering” a noun with substance.

The official Score docs cover the spec in detail. The next post in this series gets into developer onboarding flows that tie Backstage, ArgoCD, and templates into one experience.