Score Spec for Workload Portability in 2025
TL;DR — Score Spec is the workload definition that finally got the abstraction right. One
score.yamldescribes 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.yamlshould 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-compose0.22,score-k8s0.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--provisionersdoesn’t include a handler for that type. Look in the generated.score-compose/score.state.yamlto 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’soutputsdidn’t include aurlkey. Open.score-compose/score.state.yamland check the resource’s resolved state. score-k8s generatesucceeds 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’sJobresource or a Crossplane composition withreadinesshooks 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.