A thread-safe Ruby wrapper around ENV that adds Docker secrets support and optional caching.
- Docker secrets — transparently reads secrets from files via the
KEY_FILEconvention - Optional caching — configurable TTL (default 10 minutes) to avoid repeated file reads
- Thread safe — all state protected by a
Mutex - Familiar API — mirrors
Hash#fetchsemantics, raisesKeyErroron missing keys
Ruby 3.3+
Add to your Gemfile:
gem 'container_env'Or install directly:
gem install container_envrequire 'container_env'
# Raises KeyError if the key is not set
ContainerEnv.fetch('DATABASE_URL')
# Returns a default value instead of raising
ContainerEnv.fetch('DATABASE_URL', 'postgres://localhost/myapp')
# Calls the block instead of raising
ContainerEnv.fetch('DATABASE_URL') { |key| "default for #{key}" }
# [] also raises KeyError on missing keys (unlike ENV[] which returns nil)
ContainerEnv['DATABASE_URL']When a key is not found in ENV, ContainerEnv checks for a companion KEY_FILE variable.
If present, it reads the secret from the file at that path and returns its contents (trailing newline stripped).
# Instead of DATABASE_URL=postgres://user:pass@host/db
# Docker injects the secret path:
DATABASE_URL_FILE=/run/secrets/database_urlContainerEnv.fetch('DATABASE_URL')
# => reads /run/secrets/database_url and returns its contentsThis matches the Docker secrets convention and works with
Docker Swarm, Compose (secrets:), and Kubernetes (secretKeyRef mounted as files).
Lookup order: ENV[key] → ENV["#{key}_FILE"] (file read) → default / block / KeyError
Caching is disabled by default. Enable it to avoid repeated file reads in hot paths:
ContainerEnv.configure do |config|
config.cache_enabled = true
config.cache_ttl = 600 # seconds, default is 600 (10 minutes)
endCached values are stored in-process with a monotonic TTL. The cache is shared across threads.
To clear the cache and reset configuration (useful in tests):
ContainerEnv.reset!ContainerEnv.configuration.cache_enabled? # => true/false
ContainerEnv.configuration.cache_ttl # => Integer (seconds)In tests, inject a plain hash instead of ENV via ContainerEnv::Fetcher directly:
fetcher = ContainerEnv::Fetcher.new(
env: { 'DATABASE_URL' => 'postgres://localhost/test' },
cache: ContainerEnv::Cache.new(ttl: 600),
config: ContainerEnv::Configuration.new
)
fetcher.fetch('DATABASE_URL') # => 'postgres://localhost/test'Or call ContainerEnv.reset! in an after hook to clear cached state between examples:
RSpec.configure do |config|
config.after { ContainerEnv.reset! }
endClimateControl modifies ENV in-place, which ContainerEnv reads
through transparently. With caching disabled (the default) there is no incompatibility.
With caching enabled, ContainerEnv may serve a stale cached value inside a ClimateControl.modify block, or leak
a test value set inside the block into the next lookup after it. Fix this with clear_cache!, which clears
the cache without touching configuration:
RSpec.configure do |config|
config.after { ContainerEnv.clear_cache! }
endclear_cache! is a no-op when caching is disabled, so it is safe to add unconditionally.
The gem ships a ContainerEnv/PreferContainerEnv cop that flags direct ENV reads and autocorrects them to ContainerEnv.
# bad — flagged
ENV['DATABASE_URL']
ENV.fetch('DATABASE_URL', 'postgres://localhost/dev')
ENV.fetch('DATABASE_URL') { |k| "default for #{k}" }
# good — autocorrected to
ContainerEnv['DATABASE_URL']
ContainerEnv.fetch('DATABASE_URL', 'postgres://localhost/dev')
ContainerEnv.fetch('DATABASE_URL') { |k| "default for #{k}" }Write access (ENV[]=) and enumeration methods (to_h, each, replace, …) are intentionally not flagged — they have no ContainerEnv equivalent and are commonly used in test setup.
Add to your project's .rubocop.yml:
require:
- container_env/rubocop_extension
ContainerEnv/PreferContainerEnv:
Enabled: trueRun rubocop as usual — offenses are autocorrectable with -a.
bundle install
bundle exec rspec # run tests
bundle exec rubocop # lintMIT