LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Score: Developer-Centric Workload Specification

platform-engineeringdeveloper-experiencekubernetesdockerdevopscncfopen-source

Here’s a configuration problem most teams hit eventually: you have a docker-compose.yaml for local development and a Helm chart (or raw manifests) for Kubernetes. They describe the same workload, but diverge constantly. A developer adds an environment variable — it needs to go in three places. You add a new service dependency — it needs to be wired up differently in each environment. The files drift apart. Problems appear in staging that weren’t present locally.

Score is a CNCF Sandbox project (accepted August 2024) that attacks this at the source. Instead of maintaining environment-specific files, you write a single score.yaml that describes what your workload needs — containers, dependencies, ports, volumes — and Score’s CLI tools generate the environment-specific output from it. One score.yaml, one score-compose generate for Docker Compose, one score-k8s generate for Kubernetes manifests.


The Problem in Concrete Terms

A typical developer workflow without Score looks like this:

Application code
├── docker-compose.yaml        ← local dev
├── k8s/
│   ├── deployment.yaml        ← staging/prod
│   ├── service.yaml
│   ├── configmap.yaml
│   └── secret.yaml
└── helm/
    └── values.yaml            ← production overrides

Every one of these files describes the same workload. When something changes — new environment variable, new dependency, port change — you update it in multiple places and hope you didn’t miss one. Score collapses this to:

Application code
├── score.yaml                 ← single source of truth
├── docker-compose.yaml        ← generated by score-compose
└── k8s/manifests/             ← generated by score-k8s

The generated files are artifacts. The score.yaml is the source.


The score.yaml Format

A complete score.yaml for a web service with a database and cache:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
apiVersion: score.dev/v1b1
metadata:
  name: my-service

containers:
  api:
    image: my-org/my-service:${BUILD_TAG}
    command: ["/app/server"]
    variables:
      PORT: "8080"
      LOG_LEVEL: "info"
      DB_HOST: ${resources.db.host}
      DB_PORT: ${resources.db.port}
      DB_NAME: ${resources.db.name}
      DB_USER: ${resources.db.username}
      DB_PASS: ${resources.db.password}
      REDIS_URL: redis://${resources.cache.host}:${resources.cache.port}
      APP_NAME: ${metadata.name}
    resources:
      limits:
        memory: "512Mi"
        cpu: "500m"
      requests:
        memory: "128Mi"
        cpu: "100m"
    livenessProbe:
      httpGet:
        path: /healthz
        port: 8080
      initialDelaySeconds: 10
    readinessProbe:
      httpGet:
        path: /ready
        port: 8080

service:
  ports:
    web:
      port: 80
      targetPort: 8080
      protocol: TCP
    metrics:
      port: 9090
      targetPort: 9090
      protocol: TCP

resources:
  db:
    type: postgres
    params:
      version: "16"
  cache:
    type: redis
  uploads:
    type: s3

The key elements:

containers — one or more containers in the workload. Each has an image, optional command override, environment variables, resource limits, and health checks. The ${resources.db.host} syntax is how you reference provisioned resource outputs — the actual connection details get injected at generation time based on what the target platform’s provisioners provide.

service — ports the workload exposes. Named ports (web, metrics) let Score generate the right service type for each target platform.

resources — dependencies the workload needs. type: postgres, type: redis, type: s3 are the declarations. How they’re actually provisioned is the platform team’s concern, not the developer’s.

metadata.name — the workload’s identity, referenced with ${metadata.name} in variables.


score-compose: Local Development

Install:

1
2
3
4
5
6
# macOS
brew install score-spec/tap/score-compose

# Linux
curl -Lo score-compose https://github.com/score-spec/score-compose/releases/latest/download/score-compose_linux_amd64
chmod +x score-compose && sudo mv score-compose /usr/local/bin/

Initialize a project:

1
score-compose init

This creates a .score-compose/ directory with a state.yaml and default provisioner definitions. Add .score-compose/ to your .gitignore — it’s a local state directory.

Generate and run:

1
2
score-compose generate score.yaml
docker compose up --build

generate reads score.yaml, loads provisioners, and writes docker-compose.yaml. The default provisioners handle postgres, redis, mongodb, mysql, rabbitmq, and s3 (via MinIO) automatically. For postgres, you get a postgres container and the connection details injected as environment variables in your service. You didn’t configure any of that — the provisioner handled it.

Multiple workloads in one compose stack:

1
2
3
score-compose generate api/score.yaml
score-compose generate worker/score.yaml
docker compose up

Each generate call appends to the compose file. Services see each other on the Docker network.

Overrides for local tweaks:

1
2
3
4
5
6
7
# Override a specific property
score-compose generate score.yaml \
  --override-property 'containers.api.variables.LOG_LEVEL=debug'

# Override with a file
score-compose generate score.yaml \
  --override-file local-overrides.score.yaml

Overrides let you change values for local development without touching the canonical score.yaml.


score-k8s: Kubernetes Manifests

Install:

1
2
3
4
brew install score-spec/tap/score-k8s
# or
curl -Lo score-k8s https://github.com/score-spec/score-k8s/releases/latest/download/score-k8s_linux_amd64
chmod +x score-k8s && sudo mv score-k8s /usr/local/bin/

Initialize:

1
score-k8s init

Generate Kubernetes manifests:

1
2
score-k8s generate score.yaml
kubectl apply -f manifests.yaml

From the same score.yaml that generated your Docker Compose file, score-k8s generate produces:

  • Deployment — pod spec with containers, environment variables, resource limits, health checks
  • Service — ClusterIP/LoadBalancer based on the service.ports definition
  • ConfigMap — non-sensitive environment variables
  • Secret — sensitive values from provisioners (database passwords, API keys)
  • PersistentVolumeClaim — if volumes are declared

The generated manifests are complete and apply directly. No templating engine required, no Helm values to maintain.


How Resource Provisioning Works

The provisioning system is where Score gets interesting and where the platform team’s work happens.

When score-compose or score-k8s encounters resources: db: type: postgres, it looks for a provisioner that matches type: postgres. The provisioner defines how to create the resource and what outputs to expose. Those outputs (host, port, username, password, name) get injected into the container’s environment variables wherever ${resources.db.*} appears.

Default provisioners (included out of the box):

Resource type score-compose score-k8s
postgres Docker postgres container External/in-cluster postgres, credentials via Secret
redis Docker redis container External/in-cluster redis
mongodb Docker mongo container External/in-cluster MongoDB
mysql Docker mysql container External/in-cluster MySQL
s3 MinIO container S3 bucket (AWS or compatible)
rabbitmq Docker rabbitmq container External/in-cluster RabbitMQ

Custom provisioners let platform teams add anything. A provisioner is a YAML template placed in the state directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# .score-compose/custom-provisioners.yaml
- uri: template://kafka
  type: kafka
  outputs:
    brokers:
      value: "localhost:9092"
    topic:
      value: ${param.topic_name}
  init: |
    services:
      kafka-${id}:
        image: bitnami/kafka:latest
        environment:
          KAFKA_ENABLE_KRAFT: "yes"
          KAFKA_CFG_NODE_ID: "1"

Developers reference type: kafka in score.yaml. The provisioner starts a Kafka container and injects the broker URL. The developer never configures Kafka — they just declare that they need it.

For Kubernetes, provisioners can generate actual cloud resources (RDS instances, ElastiCache clusters) via Terraform or CloudFormation, then inject the resulting connection strings as Secrets. This is the platform team’s domain: write the provisioner once, every workload that needs postgres gets a properly provisioned database without touching infrastructure code.


Complete Workflow

Developer side:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Write score.yaml alongside application code
cat > score.yaml << 'EOF'
apiVersion: score.dev/v1b1
metadata:
  name: api-service
containers:
  api:
    image: ghcr.io/my-org/api:latest
    variables:
      DATABASE_URL: postgresql://${resources.db.username}:${resources.db.password}@${resources.db.host}:${resources.db.port}/${resources.db.name}
      REDIS_ADDR: ${resources.cache.host}:${resources.cache.port}
resources:
  db:
    type: postgres
  cache:
    type: redis
service:
  ports:
    http:
      port: 80
      targetPort: 8080
EOF

# Local development
score-compose init
score-compose generate score.yaml
docker compose up

# Done — a postgres container and redis container are running,
# DATABASE_URL and REDIS_ADDR are injected correctly.

CI/CD pipeline:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# .github/workflows/deploy.yaml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install score-k8s
        run: |
          curl -Lo score-k8s https://github.com/score-spec/score-k8s/releases/latest/download/score-k8s_linux_amd64
          chmod +x score-k8s && sudo mv score-k8s /usr/local/bin/

      - name: Init score-k8s
        run: score-k8s init

      - name: Generate manifests
        run: score-k8s generate score.yaml -o manifests.yaml

      - name: Validate (dry run)
        run: kubectl apply -f manifests.yaml --dry-run=client

      - name: Deploy
        run: kubectl apply -f manifests.yaml

The same score.yaml that the developer tested locally is the source for the Kubernetes manifests deployed to production. The outputs differ (Docker Compose vs Kubernetes manifests), but the workload specification is identical.


Multi-Container Workloads

Score supports multiple containers per workload by defining multiple entries under containers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
apiVersion: score.dev/v1b1
metadata:
  name: web-app

containers:
  app:
    image: my-org/app:latest
    variables:
      PORT: "8080"
      DB_URL: postgresql://${resources.db.username}:${resources.db.password}@${resources.db.host}/${resources.db.name}

  sidecar:
    image: envoyproxy/envoy:latest
    variables:
      UPSTREAM_HOST: "localhost"
      UPSTREAM_PORT: "8080"

service:
  ports:
    http:
      port: 80
      targetPort: 9901  # envoy port

resources:
  db:
    type: postgres

Both containers share the same pod in Kubernetes and the same Docker network in Compose. Init container semantics and Kubernetes sidecar restart policies are implemented at the platform level — score-k8s generates a Deployment that the Kubernetes scheduler handles natively.


Score and Internal Developer Platforms

Score’s design makes it a natural fit as the workload specification layer in an Internal Developer Platform (IDP). The separation of concerns maps directly to the platform engineering model:

Developers own: score.yaml — what the workload needs (image, env vars, dependencies, ports)

Platform teams own: Provisioners — how those needs are satisfied in each environment (which database tier, which security group, which naming convention, which cloud region)

This means a developer can declare type: postgres without knowing whether that’s a Docker container locally, an RDS instance in staging, or an Aurora cluster in production. The provisioner layer makes that decision, consistently, without developer involvement.

The workload is portable across environments not because magic made it so, but because the interface between the developer’s specification and the platform’s implementation is cleanly defined.

Humanitec (Score’s creator) built their commercial Platform Orchestrator on this model. But the spec and the CLI tools are open-source and CNCF-governed — you can build your own provisioners for any target platform without touching Humanitec’s product.


Comparison with Alternatives

Score Docker Compose Helm Acorn Waypoint
Multi-platform Yes No (Compose only) No (K8s only) No (K8s only) Yes
Developer-facing Yes (simple spec) Yes (but env-specific) No (complex templating) Yes Partial
Provisioner system Yes No No Yes Plugin-based
GitOps-friendly Yes Partial Yes Yes Partial
CNCF Sandbox No No No No
Lock-in Low (open spec) Low Low Medium Medium

Score’s unique position: it’s the only tool that provides a platform-agnostic workload spec without requiring you to adopt a specific runtime or platform. It generates artifacts for your existing tools rather than replacing them.


Current Maturity

Stable and production-ready:

  • The score.dev/v1b1 specification — stable, versioned
  • score-compose — production-ready for local development
  • score-k8s — production-ready for Kubernetes deployments
  • Core resource types: postgres, redis, mongodb, mysql, s3, rabbitmq

Growing/experimental:

  • Additional platform implementations (Cloud Run, Nomad, ECS, Fly.io — community projects)
  • Advanced provisioner features
  • Init container and sidecar specification at the Score level (currently delegated to implementations)

Known limitations:

  • Only supports OCI containers — not serverless functions, VMs, or other workload types
  • Platform teams must build and maintain provisioners — Score doesn’t auto-provision infrastructure
  • Assumes an existing orchestration platform (Docker or Kubernetes) — Score doesn’t run workloads itself
  • The ecosystem is growing but small compared to Helm’s plugin library

When Score Makes Sense

Score is a good fit when:

  • Your team maintains parallel configs for local, staging, and production environments that keep diverging
  • You’re building or operating an IDP and need a clean workload specification interface for developers
  • You want developer portability — developers should be able to describe workloads without knowing Kubernetes YAML
  • Your stack is containers on Kubernetes — Score’s strongest support is for this combination

It’s probably not worth the investment when:

  • You have a small team with a simple, stable stack where configuration drift isn’t a real problem
  • You need non-container workloads (functions, managed services, bare-metal)
  • You don’t have a platform team to build and maintain provisioners

Getting Started

The fastest path to understanding Score is running it against a real service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Install both CLIs
brew install score-spec/tap/score-compose score-spec/tap/score-k8s

# Create a score.yaml for an existing app
cat > score.yaml << 'EOF'
apiVersion: score.dev/v1b1
metadata:
  name: my-app
containers:
  app:
    image: nginx:alpine
    variables:
      GREETING: "hello from score"
service:
  ports:
    http:
      port: 80
      targetPort: 80
EOF

# Run locally
score-compose init
score-compose generate score.yaml
docker compose up -d
curl http://localhost:80

# Generate Kubernetes manifests
score-k8s init
score-k8s generate score.yaml
cat manifests.yaml

The manifests are readable, standard Kubernetes YAML. Score doesn’t abstract Kubernetes away — it generates idiomatic Kubernetes resources that you can inspect, modify, and apply with standard tooling.


The Bottom Line

Score solves configuration sprawl by making the workload specification the single source of truth instead of making environment-specific configuration files the source of truth. The developer writes what they need once; platform teams define how those needs are fulfilled per environment.

It’s not magic and it doesn’t eliminate platform complexity — the provisioner system still requires platform engineering work. But it puts that complexity in the right place: platform teams handle it once, and developers stop maintaining parallel configuration files.

For teams already investing in platform engineering and IDPs, Score is worth evaluating as the workload specification layer. The CNCF backing and Apache 2.0 license mean you’re not betting on a vendor’s continued interest.

Comments