One thing I never appreciated until I ran the same jar across dev, staging, and prod is how much of "config pain" is really "config layering pain." Where does a value come from? Which file wins? How do I keep a database password out of Git without wrapping the whole thing in a secrets manager on day one?

Solon has a pretty clean answer to all of this, and it fits in your head. Here's the mental model I use.

The 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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

# (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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

The ${solon.env} interpolation means one line covers every environment. Notice the prefix semantics:

  • classpath:xxx — resource inside the jar
  • file:xxx — external file next to the jar
  • xxx (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

Enter fullscreen mode Exit fullscreen mode

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.

  1. L1 — Main config: internal app.yml and app-{env}.yml
  2. L2 — Internal extension files: added via solon.config.load
  3. L3 — External local files: added via solon.config.add
  4. L4 — Dynamic config: environment variables and system properties (any solon-prefixed env var is synced into both system properties and Solon.cfg())
  5. L5 — Startup interface loading: Solon.cfg().loadAdd(...) / loadEnv(...) in your bootstrap
  6. 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.");
        });
    }
}

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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 = true makes 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
    }
});

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Generate ciphertext with the utility:

System.out.println(VaultUtils.encrypt("root"));

Enter fullscreen mode Exit fullscreen mode

Then store the encrypted values wrapped in ENC(...):

solon.vault:
  password: "liylU9PhDq63tk1C"

test.db1:
  url: "..."
  username: "ENC(xo1zJjGXUouQ/CZac55HZA==)"
  password: "ENC(XgRqh3C00JmkjsPi4mPySA==)"

Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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.yml holds structure and safe defaults, plus solon.config.load lines that pull in ${solon.env}-scoped datasource and auth files.
  • app-dev.yml / app-pro.yml carry the environment differences.
  • Real secrets go through the vault as ENC(...), with the vault password passed via -D at 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.