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 Type | Binary | Home Directory | Key Config Differences |
|---|---|---|---|
| Cosmos SDK | osmosisd, gaiad, etc. | /root/.osmosisd | prefix, denom, staking, faucet |
| Ethereum | geth + prysm | /root/.ethereum | execution + consensus layers, two processes |
| Solana | agave-validator | /root/.solana | validator 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: 2Everything 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 versionEverything 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: ethAll 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:
| Level | Source | Example |
|---|---|---|
| 1 (highest) | User chain config | numValidators: 4 in user's config file |
| 2 | Chain-specific defaults | osmosis.prefix: osmo in defaults.yaml |
| 3 | Component defaults | Default faucet settings, default scripts |
| 4 | Global defaults | Resource limits, exposer image, common settings |
| 5 (lowest) | Hardcoded defaults | Values 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:
- Add an entry to
defaults.yamlwith the chain's image, home directory, binary, and chain-specific fields - The JSON schema (
values.schema.json) validates the new entry's structure - TypeScript types auto-generate from the schema via
@starship-ci/types - 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
- Starship: Universal interchain development environment
- Starship Docs: Official documentation
- @starship-ci/types: TypeScript types generated from the config schema
- JSON Schema Specification: The JSON Schema standard
- Cosmos SDK: Framework for application-specific blockchains