Sideeffects of new `${ENV:<MY_VAR>}` based thing configurations

This is more complicated it seems - the configuration is sometimes written back by the binding, in which case it must use the “unsubstituted” configuration. Then there is the issue of discovery autoupdating discovered parameters…

For a password, discovery isn’t a factor, but for things like IP addresses… but why would they even need to be “hidden”? For IPv4, they should be benign, private IPs. For IPv6 it’s a different matter though, because the IP can contain the MAC address.

One thing is that I think getRawConfig() must be protected, not private, because bindings that do complicated stuff needs to be able to get the unsubstituted configuration.

I’ve been looking at this for a while now, and I haven’t yet been able to find a way it can be handled “properly”.

Configuration aren’t a one-way thing, bindings also write to configurations. By converting the configuration before the bindings receive them in some paths and not in others, it’s very hard keep track of this. The binding doesn’t know “which version” is has, and how do you handle updates? If the binding wants to write to a configuration value that has an env variable, it has no way to “convert it back” (which would be impossible anyway, unless they actually then changed the OS variable) or even know that they’re dealing with a substituted setting.

On top of this comes the “automatic update” of configuration variables, that goes a bit like this: Any configuration parameter that has the same key as any of the “discovery properties” will automatically be overwritten when discovery runs. This will also overwrite any environment variables stored there, thus “exposing” what the user is trying to keep secret.

I’m just not sure if this is doable in this way.

Maybe instead, we could make a special wrapper type SecretHolder whose toString() method would just return the environment variable, and you had to call some other method e.g. resolve() to get the resolved value? This “wrapper” could then hold the logic for how variables are resolved. But, there are lots of challenges there too, for example, it would only work when bindings explicitly expected that a SecretHolder might be used for that parameter, and called resolve() when needed. So, it wouldn’t apply “universally” like this tries to do. Also, how would we get the UI to create such a SecretHolder instead of a String. I guess the REST API could scan for a pattern, and create a wrapper if the pattern fits. The same would have to be done in the file configuration parsers (DSL/YAML).

I just don’t know, at the moment I don’t see a “clear path”.

edit: Here is another reason why I don’t like when String is used everywhere. String in Java is final and can’t be extended. CharSequence on the other hand, is an interface, and can be used instead in most places. If those were used throughout, it would be easy to make the “wrapper” implement CharSequence and then go on their merry way, being treated like “just another string” throughout the code (where they would only return the unsubstituted value). Only when resolving them was needed, they could pass through some utility function that resolved them if they were in fact wrapper implementations, and otherwise just passed them through. But, since that’s not what has been done in OH, we don’t have any such possibility. String is set in stone, and we can’t do anything to pass other implementations through.

@holger_hees One thing I’m wondering, is why using this for IP addresses etc. simplifies things for you. It doesn’t sound “simpler” to me to have to set them than to have them set in files. Is that because you run some scripts that somehow automatically sets the environment variables?

Have you considered using YAML configuration instead, in combination with YAML Composer? It gives quite a few possibilities to build “dynamic” configurations and do substitutions. I don’t know your exact needs though.

That’s why I mentioned at the beginning that I might have gone a bit overboard. :slight_smile: But it serves as a good extreme example for testing purposes. Besides, the configs on GitHub don’t contain any network-related information, since everything has been externalized.

But I agree, it is not necessary.


just an example

robonect:mower:automower "Rasenmäher" [ host="${ENV:CUSTOM_AUTOMOWER_IP}", pollInterval="15", offlineTimeout="300", user="${ENV:CUSTOM_AUTOMOWER_USERNAME}", password = "${ENV:CUSTOM_AUTOMOWER_PASSWORD}"]

Username and password makes sense, I’m not sure I understand why the IP does. But regardless, there are some unresolved challenges here.

@jimtng I don’t remember all that the add-on can do, only that it can do just about anything except maybe fly. You know it inside out, is there any way to use it for a similar purpose?

The openhab container itself is automatically deployed with my ansible playbook. And ansible has all these information and is creating all necessary env vars.

I just maintain a central yaml file which describes my whole house. IP’ addresses, Networks, VLANs, Credentials encrypted in a vault. etc. From theese yaml file I deploy everything. ~30 Services (including openhab) on my server, openwrt on switches, AP’s and gateways…

Is just a design philosophy from my side. Years ago, I started to document everything what I deploy or configure somewhere at home. And at some point, the documentation was not maintainable anymore, because it was getting bigger and bigger. And this was the starting point to automate everything and use the deployment together with the yaml file as my documentation to describe what I do and where.

The result is that the deployment itself is on github. The yaml file is encrypted on a external backup. And if I destroy every at home for whatever reason, it just cost me new hardware, a internet connection and 1 click :slight_smile: … ok maybe 1-2 hours to restore (deploy) my whole infrastructure.

So the YAML file is my single source of truth.

Yes, but can’t Ansible do anything else than set env. variables? If it could deploy a YAML file with some of this configuration, e.g. YAML Composer could pick them up and merge them in where they’re needed.

That wouldn’t work for secret keeping though, because the YAML is “composed” before being imported into OH, so all values would already have been substituted - but it should allow for a “hands-free” configuration?

@florian-h05 While studying this, I’ve done various minor changes to related code. It won’t solve any of the problems, so I haven’t bothered to create a PR, but I have pushed them to a branch in case they are of any interest.

My idea with making a type-safe version of “resolving variables” was to make it simpler if bindings need to iterate and substitute manually. Getting Object in return leads to a lot of nasty casting. But, it can’t handle collections, because there’s no way to create new instances of the same type as the one you’re served.

Anyway, here they are:

yes, and

yes.

I should also document the other syntax i.e. ${ENV["ENV_NAME"]} for completeness.

In the case of YAML Composer ENV replacement, you would see the actual value of that env var at the time of parsing into the final YAML file and therefore you would also see the actual value in MainUI.

To use the core’s built in ${ENV:ENV_NAME} inside a yamlcomposer source, you must prevent YAML Composer’s replacement either by using a custom pattern or using !literal for that particular YAML node. This was done before core’s ENV feature existed, so it wasn’t mentioned in the doc. Now that core has its own mechanism, I should document how to avoid conflicts in the pattern.

YAML Composer’s ENV can be used anywhere, even as a replacement for Thing UID, Item name, etc.

My ansible playbooks much be really ineffecient. It takes almost an hour just to upgrade everything. Though I also have tripwire scans in there that takes a good bit of time.

I may need to take a look at how you’ve structured your playbooks.

But I too use Ansible for almost everything. I tend to do most of the network stuff by hand largely because it doesn’t change much once I’ve set it. But everything else is Ansible playbook.

I like how it minimizes how much I need to back up. It lets me stay well within my budget for offsite backups.

Whebn I saw this post I started to get ideas like this too. :slight_smile:

If you are using community.docker.docker_compose to bring up containers, environment variables are way easier to set up compared to a template file that you deploy or a config file you edit or build up progommatically in the role. If it’s possible to use environment variables, that’s definitly the way to go.

@ jimtng I converted my first thing configs from dsl to yaml, using yaml composer and it works perfect! So thanks for the hint.

  shelly:shellyplugs:socket1:
    label: "Steckdose Lüfter"
    config:
      deviceIp: ${ENV.CUSTOM_SHELLY_SOCKET_1_IP}
      password: !literal ${ENV:CUSTOM_SHELLY_SOCKET_PASSWORD}

deviceIp is replaced during parsing and is visible inside the UI and password is replaced during runtime and is not visible inside the UI.

on problem I found with the gardena binding. The runtime replacement does not work at all here. I still have to use the replacement during parsing.

  gardena:account:default:
    isBridge: true
    label: Bodenfeuchte Gardena Smart System API
    config:
      apiSecret: ${ENV.CUSTOM_GARDENA_SECRET}
      apiKey: ${ENV.CUSTOM_GARDENA_KEY}
      connectionTimeout: 10

if I change the secret to runtime replacement, it is not replaced during runtime. I debugged the binding a bit and found out that the binding is sending ${ENV:CUSTOM_GARDENA_SECRET} unprocessed as a secret.

  gardena:account:default:
    isBridge: true
    label: Bodenfeuchte Gardena Smart System API
    config:
      apiSecret: !literal ${ENV:CUSTOM_GARDENA_SECRET}
      apiKey: ${ENV.CUSTOM_GARDENA_KEY}
      connectionTimeout: 10
2026-07-12 10:12:49.476 [DEBUG] [ternal.handler.GardenaAccountHandler] - Initializing Gardena account 'default'
2026-07-12 10:12:49.476 [DEBUG] [ternal.handler.GardenaAccountHandler] - GardenaConfig[connectionTimeout: 10, apiKey: ${ENV:CUSTOM_GARDENA_KEY}]
2026-07-12 10:12:49.477 [DEBUG] [ng.gardena.internal.GardenaSmartImpl] - Starting GardenaSmart
2026-07-12 10:12:49.479 [DEBUG] [ng.gardena.internal.GardenaSmartImpl] - Gardena API login using apiSecret, reason: no token available

just to mention this for the completeness.

It looks like the ENV replacement doesn’t work on Bridges.

There are many, many situations under which it doesn’t work. The implementation makes a lot of assumptions, and will only work in very simple cases.

Are you working on solving this, @Nadahar ?

If not, I want to have a crack at it (with no guarantee of success!), but if you are already working on it, I can find some other puzzles to work on.

No, I couldn’t find a way. Look at the factors mentioned (above I think) with the fact that bindings process configurations themselves, fetches values, handles updates etc. Making this work without modifying lots bindings doesn’t seem possible to me.

The perhaps biggest problem is that many bindings also write the configuration back. If you substitute the variable before the binding receive the configuration, how to you handle when the binding wants to save it?

It’s also the fact that discovery will automatically update any configuration keys that match discovery property keys while OH is running… I have no idea how that could be handled.

The only thing I did do was make this commit: Various tweaks to configuration variable processing · Nadahar/openhab-core@5b73ff2 · GitHub

It contains some, what I consider, improvements to some details. If you want to work on this, feel free to use whatever you want from that commit, but the commit doesn’t solve any of the actual problems, it just makes some things a bit easier.

My idea is still vague right now as I haven’t really tried anything yet. I was thinking that we could tackle this in the Configuration class. When a value is stored, we store the original copy as a separate value, say in a mirror properties object, and then resolve the env vars in the real config value.

Since the only places we need to use the original (unresolved) config are limited to:

  • REST
  • jsondb persistence

only these two places would need to handle the config in a special way - request the mirrored (unresolved) properties instead of the normal (resolved) properties.

Everything else will get the resolved values.

The benefit is that anything that uses the Configuration can benefit from ENV expansion. Hopefully there will be fewer challenges due to this.

What do you think?

Yeah, it sounds like a path that might be doable. It’s important to remember that bindings often deal with the configuration as plain key/value maps, but if that all happens in the “decoded” sphere, it shouldn’t pose any problems.

The “downside” is that the “secrets” will be available to everything internal, like a script/rule or an add-on - instead of being “isolated” inside the binding. Ideally, it would be “undecoded” all the way until it crossed the boundary to the binding, but it might be very hard to achieve.

Also remember that things like YAML/DSL serialization must use the “undecoded” values.

Hypothetically speaking, if an attacker gains access to the internal memory and can see the undecoded (i.e. raw) value, they could also gain access to the corresponding env var anyway.

Yes, but that’s not what I meant here. A simple rule/script can acquire the Thing and just call Thing.getConfiguration() to get the decoded values. Some of the point of this was to keep the secrets “safe” from the cloud AI models, they can easily access them this way.

That would also require lots of changes in core, but still less than in openhab-addons.
Originally when I developed that feature, I expected that all ThingHandlers use getConfiguration() t get the Thing config as this is how I understood the docs and also the code I looked at when developing my first bindings …

Unfortunately, the “standard way” is very limited and inflexible, so “more advanced” bindings are likely to handle this themselves.