Solon Config & Multi-Environment Management: The Layering Model That Fits in Your Head
Main Config File and Environment Switching
Your application config lives at resources/app.yml (or app.properties). It always loads. That's the base. Environment-specific files sit next to it with an app-{env} name:
resources/
app.yml # always loaded (base)
app-dev.yml # dev overrides
app-pro.yml # prod overrides
You pick the environment with solon.env. There are four ways to set it, and they matter because they layer from least to most dynamic:
# (1) in app.yml itself
solon.env: dev
# (2) system property
java -Dsolon.env=pro -jar demo.jar
# (3) startup argument
java -jar demo.jar --env=pro
# (4) environment variable (great for containers)
docker run -e 'solon.env=pro' demo_image
The higher the number, the higher the priority. So a value baked into app.yml can be overridden by a -D system property, which in turn can be overridden by a container env var. That ordering is the whole trick - you set safe defaults inside the jar and let the deployment environment push the real values on top.
One rule worth remembering: Solon will automatically load app-{env}.yml, but a file loaded because of an environment can't itself declare a new environment. No recursive env-hopping.
Loading More Config than Just app.yml
Real apps outgrow a single file. You want datasource config in one place, auth in another, maybe a shared common block. Solon gives you solon.config.load for that (an array form), and it understands path expressions:
solon.config.load:
- "classpath:${solon.env}/jdbc.yml" # env-scoped internal file
- "classpath:${solon.env}/*.yml" # wildcard (internal only)
- "file:common/*.yml" # wildcard (external only)
- "docs.yml" # external first, else internal
The ${solon.env} interpolation means one line covers every environment.
Notice the prefix semantics:
classpath:xxx- resource inside the jarfile:xxx- external file next to the jarxxx(no prefix) - try external first, fall back to the resource
If you'd rather specify extra config at launch instead of in a file, there's a single-value sibling, solon.config.add, aimed at runtime:
java -jar demo.jar --solon.config.add=./demo.yml
# or
java -Dsolon.config.add=./demo.yml -jar demo.jar
The convention I've settled on: put anything ops needs to change at deploy time into an external file (solon.config.add / file: load), and keep everything else internal. It keeps the jar portable and the surface for accidental prod edits small.
The Full Loading Order
When two files set the same key, who wins? Solon defines six layers, and the rule is simple: the more static, the earlier; the more dynamic, the later - and later overrides earlier, key by key.
- L1 - Main config: internal
app.ymlandapp-{env}.yml - L2 - Internal extension files: added via
solon.config.load - L3 - External local files: added via
solon.config.add - L4 - Dynamic config: environment variables and system properties (any
solon-prefixed env var is synced into both system properties andSolon.cfg()) - L5 - Startup interface loading:
Solon.cfg().loadAdd(...)/loadEnv(...)in your bootstrap - L6 - Cloud config: e.g. Nacos via a Solon Cloud plugin
Once you internalize "later beats earlier," almost every "why is this value not what I set?" question answers itself. Your prod container's env var (L4) will always win over the default in app.yml (L1).
There's also a code path for L5 if you want programmatic control:
public class App {
public static void main(String[] args) {
Solon.start(App.class, args, app -> {
app.cfg().loadAdd("file:config/demo.yml");
// load env vars by prefix - handy when building a docker image
app.cfg().loadEnv("demo.");
});
}
}
Getting Config Into Your Code
Two ways: inject it, or fetch it.
Injection uses @Inject with a ${...} expression:
@Component
public class DemoService {
// single value with a default
@Inject("${track.name:demoApi}")
String trackName;
// a whole structured block bound to an object
@Inject("${track.db1}")
Properties trackDbCfg;
// Solon can even build a bean from a config block
@Inject("${track.db1}")
HikariDataSource trackDs;
}
Bind a config section to a typed class so you can reuse it anywhere:
@Inject("${user.config}")
@Configuration
public class UserProperties {
public String name;
public List<String> tags;
}
// elsewhere
@Inject UserProperties userProperties;
Since v3.0.7 there's also @BindProps(prefix=...) if you prefer prefix binding over an explicit expression.
Prefer the manual route sometimes? Solon.cfg() gives you direct access:
String trackName = Solon.cfg().get("track.name", "demoApi");
Properties dbCfg = Solon.cfg().getProp("track.db1");
HikariDataSource ds = Solon.cfg().getBean("track.db1", HikariDataSource.class);
A couple of details that saved me time:
- If the expression name is all uppercase and no matching config key exists, Solon tries an environment variable instead. So
@Inject("${JAVA_HOME}")reads the env var. Nice for container-first setups. autoRefreshed = truemakes an injected field track live config changes - but only enable it on singletons and field injection. On a non-singleton it makes no sense, so leave it off.
For anything more reactive, you can subscribe to changes directly:
Solon.cfg().onChange((key, val) -> {
if (key.startsWith("track.name")) {
// react to the change
}
});
Keeping Secrets Out of Plain Text
Multi-environment config eventually runs into "I don't want the prod DB password sitting in a yml in the repo." Solon's lightweight answer is solon-security-vault. It's not a full secrets vault - the docs are honest about that: it desensitizes values so they aren't sitting in the open, it doesn't make them uncrackable. For keeping a password out of a plain-text file, that's usually the right amount of protection.
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-security-vault</artifactId>
</dependency>
You set a vault password (16 chars, mixed case + digits recommended), ideally passed at launch rather than committed:
java -Dsolon.vault.password=xxx -jar demo.jar
Generate ciphertext with the utility:
System.out.println(VaultUtils.encrypt("root"));
Then store the encrypted values wrapped in ENC(...):
solon.vault:
password: "liylU9PhDq63tk1C"
test.db1:
url: "..."
username: "ENC(xo1zJjGXUouQ/CZac55HZA==)"
password: "ENC(XgRqh3C00JmkjsPi4mPySA==)"
And inject with the dedicated @VaultInject, which decrypts as it binds:
@Configuration
public class TestConfig {
@Bean("db2")
DataSource db2(@VaultInject("${test.db1}") HikariDataSource ds) {
return ds;
}
}
Need to decrypt by hand? VaultUtils.guard(...) handles both a config block and a single value.
How I'd Put It Together
For a typical service across three environments:
app.ymlholds structure and safe defaults, plussolon.config.loadlines that pull in${solon.env}-scoped datasource and auth files.app-dev.yml/app-pro.ymlcarry the environment differences.- Real secrets go through the vault as
ENC(...), with the vault password passed via-Dat launch. - The environment itself is selected by a container env var (
solon.env=pro), which sits high in the loading order and wins cleanly.
The nice part is that none of these pieces are special-cased. It's one layering rule - more dynamic wins - applied consistently from a yml key all the way up to a Nacos value. Once that clicks, config stops being a source of surprises.
If you want to go deeper, the official docs on config loading order and path expressions are worth a read: they spell out every layer with examples.
Comments
No comments yet. Start the discussion.