Architecting Out Loud
Back to posts
configurationdeveloper-experiencearchitecture

Defaults That Scale: One Config File for 30+ System Types

Feb 22, 20268 min read

You support 30 system types. Each has different binaries, home directories, port conventions, and initialization requirements. Every one of them needs production-ready defaults. You have one file.


The 30-Chain Problem

Starship is a universal interchain development environment. It spins up blockchain networks on Kubernetes for testing: Cosmos SDK chains (osmosis, cosmos hub, juno, wasmd), Ethereum (geth + prysm), Solana (agave-validator), and more.

These aren't minor variations. They're fundamentally different runtimes.

System TypeBinaryHome DirectoryKey Config Differences
Cosmos SDKosmosisd, gaiad, etc./root/.osmosisdprefix, denom, staking, faucet
Ethereumgeth + prysm/root/.ethereumexecution + consensus layers, two processes
Solanaagave-validator/root/.solanavalidator identity, vote account, no prefix/denom concept

A Cosmos chain needs a bech32 prefix and a denomination token. Ethereum needs both an execution layer and a consensus layer configured separately. Solana doesn't have either concept. Yet all of them need to be spun up with a single user config file.

The user shouldn't have to know which fields apply to which runtime. They should write a three-line config and get a working network.


One File, All Defaults

The solution is defaults.yaml, a single file containing production-ready default configurations for every supported chain type. Each entry specifies the minimum viable configuration for that chain:

# Simplified structure of defaults.yaml
chains:
  osmosis:
    image: ghcr.io/osmosis-labs/osmosis
    home: /root/.osmosisd
    binary: osmosisd
    prefix: osmo
    denom: uosmo
    faucet:
      enabled: true
  cosmoshub:
    image: ghcr.io/cosmos/gaia
    home: /root/.gaia
    binary: gaiad
    prefix: cosmos
    denom: uatom
  ethereum:
    image: ethereum/client-go
    home: /root/.ethereum
    binary: geth
    # No prefix, no denom. Different shape entirely.

A user's config file can be as simple as:

chains:
  - name: osmosis
    numValidators: 2

Everything else (image, binary, home directory, faucet config, port mappings) comes from the defaults. The user only specifies what they want to override.

What the resolved config looks like

Those two fields expand into a fully populated chain definition after the merge:

chains:
  - name: osmosis
    numValidators: 2
    image: "ghcr.io/osmosis-labs/osmosis"
    home: "/root/.osmosisd"
    binary: "osmosisd"
    prefix: "osmo"
    denom: "uosmo"
    faucet:
      enabled: true
      type: "cosmjs"
      concurrency: 5
    ports:
      rpc: 26657
      rest: 1317
      grpc: 9090
    resources:
      cpu: "0.5"
      memory: "500M"

The user writes two fields. The system fills in the rest.

Overrides when you need them

Need to pin a specific Osmosis version? Override one field:

chains:
  - name: osmosis
    image: "ghcr.io/osmosis-labs/osmosis:v25.0.0"  # pin a specific version

Everything else still comes from defaults. You only specify what's different.

Multi-ecosystem in one file

Three ecosystems, three entries:

chains:
  - name: osmosis
  - name: cosmoshub
  - name: eth

All the images, binaries, ports, and genesis config come from defaults. One config file spins up a Cosmos SDK chain, a Cosmos Hub node, and a local Ethereum network.


Deep Merge Semantics

The deep merge engine is what makes this work. It recursively combines the user's config with the chain-specific defaults, letting user values override defaults at any depth.

flowchart LR
    A["User Config\n(sparse overrides)"] --> C["deepMerge()"]
    B["Chain Defaults\n(full config)"] --> C
    C --> D["Merged Config\n(complete, validated)"]

The deepMerge function walks both objects recursively. For each key, if both sides have an object, it recurses. If the user provides a value, it wins. If only the default has a value, it's kept.

Here's the critical gotcha: arrays replace entirely, they don't merge.

If the defaults define three genesis accounts and the user specifies one, the result is one account. Not four. This is intentional (merging arrays element-by-element is ambiguous and almost always wrong), but it trips people up.

# Default
genesis_accounts:
  - name: validator
    coins: "1000000uosmo"
  - name: faucet
    coins: "5000000uosmo"
 
# User override
genesis_accounts:
  - name: custom
    coins: "999uosmo"
 
# Result: only the user's array. The defaults are gone.
genesis_accounts:
  - name: custom
    coins: "999uosmo"

If you override any element of an array, you own the entire array. This is the single most common source of confusion in the config system.


The Five-Level Hierarchy

Configuration doesn't just merge two layers. There are five levels of precedence, and they resolve in a strict order:

LevelSourceExample
1 (highest)User chain confignumValidators: 4 in user's config file
2Chain-specific defaultsosmosis.prefix: osmo in defaults.yaml
3Component defaultsDefault faucet settings, default scripts
4Global defaultsResource limits, exposer image, common settings
5 (lowest)Hardcoded defaultsValues embedded in the DefaultsManager constructor

The DefaultsManager class processes this hierarchy through dedicated methods. processChain handles per-chain merging, including nested faucet and CometMock settings. processRelayer handles relayer-specific config. applyDefaults is the main entry point that orchestrates all of it.

The key detail: BuilderManager calls applyDefaults in its constructor. By the time any builder (the thing that generates Kubernetes YAML) touches a config object, it's already fully merged and validated. No builder ever sees a partial config. No builder needs to know about defaults.


Schema and Types

The defaults system doesn't run on trust. A JSON Schema, values.schema.json, defines the entire configuration surface: every property, every type, every required field. It's the contract between three systems: the defaults engine, the merge pipeline, and the type generator.

{
  "type": "object",
  "properties": {
    "chains": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "image": { "type": "string" },
          "home": { "type": "string" },
          "binary": { "type": "string" },
          "prefix": { "type": "string" },
          "denom": { "type": "string" }
        },
        "required": ["id", "name"]
      }
    }
  }
}

Nothing gets into the config without the schema knowing about it. A typo in a field name, a wrong type, a missing required field: the schema catches it before anything touches a cluster.

Beyond validation, the schema feeds a TypeScript type generation pipeline. The @starship-ci/types package contains types generated directly from values.schema.json. When the schema changes, the types change. When the types change, any config code with a mistake fails at compile time.

flowchart LR
    S["values.schema.json"] --> V["Runtime Validation"]
    S --> T["TypeScript Type Generation"]
    T --> P["@starship-ci/types package"]
    P --> B["BuilderManager"]
    B --> K["Kubernetes Manifests"]

Catching a typo in a chain config at compile time instead of debugging it in a pod log at 11 PM. That's the entire pitch.


Adding Chain #31

This is where the architecture pays off. Adding a new chain type is a bounded, predictable operation:

  1. Add an entry to defaults.yaml with the chain's image, home directory, binary, and chain-specific fields
  2. The JSON schema (values.schema.json) validates the new entry's structure
  3. TypeScript types auto-generate from the schema via @starship-ci/types
  4. The deep merge engine handles it identically to existing chains

No new merge logic. No special cases. No "if chain is solana, do something different" branches. The same five-level hierarchy applies to chain #31 exactly as it does to chain #1.

flowchart TB
    A["Add entry to defaults.yaml"] --> B["Schema validates structure"]
    B --> C["Types auto-generate"]
    C --> D["deepMerge handles it automatically"]
    D --> E["User writes 3-line config, gets a working network"]

The test for a good defaults system: can someone add a new system type without modifying the merge logic? If yes, you've separated the data from the machinery.


V1 to V2

The defaults system went through a significant architectural evolution.

V1 (Helm-based) used Go templates in Helm charts for merging. Configuration lived in charts/devnet/defaults.yaml, and the merge logic was embedded in template directives. This worked, but Go templates are notoriously hard to debug. When a merge produced unexpected output, you'd stare at nested {{ if }} blocks trying to figure out which branch was taken. Testing was manual: deploy, check, fix, redeploy.

V2 (TypeScript-based) moved the merge logic to a programmatic DefaultsManager class. The deepMerge function is plain TypeScript, unit-testable, and type-safe. The entire configuration pipeline (schema validation, default application, type generation) runs as a build step, catching problems before anything touches a cluster.

The defaults file didn't fundamentally change. The data stayed the same. What changed was the machinery around it, and that made the difference between "works if you're careful" and "works, and tells you when it doesn't."


Resources