Managing secrets in Ruby on Rails without dotenv or credentials.yml.enc
Frameworks
A Rails app usually ends up with two secret systems. config/credentials.yml.enc
holds whatever someone put there when the app was generated, unlocked by
RAILS_MASTER_KEY. Then dotenv-rails gets added because the database URL,
the Redis URL, and the third-party keys are easier to handle as ENV, and
because database.yml and every gem's config already read ENV. Now there's an
encrypted file nobody edits and a plaintext file everybody does.
You can collapse that to one place and keep the ENV[...] reads. Add the gem
and one line:
# Gemfile
gem "seekrit-sdk"
# config/application.rb
config.seekrit.autoload = true
With SEEKRIT_TOKEN set, the Railtie resolves the environment and fills ENV
before config/environments/*.rb loads. ENV["DATABASE_URL"],
ENV.fetch("REDIS_URL"), and <%= ENV["X"] %> in database.yml all work as
they did with a .env. Without the token set, it does nothing and nothing hits
the network at boot.
Rails credentials or environment variables?
Both are legitimate, and the distinction matters for what follows:
credentials.yml.enc | ENV | |
|---|---|---|
| Encrypted at rest in the repo | Yes | No (a .env is plaintext) |
Readable by gems and database.yml | Only via Rails.application.credentials | Yes, directly |
| Per-environment values | credentials/production.yml.enc etc. | Whatever is set at boot |
| Who can decrypt | Anyone with RAILS_MASTER_KEY | Anyone who can read the process or the file |
| Rotation | Edit, commit, deploy | Change the value, restart |
The real weakness of credentials is the master key: it's one static secret
that unlocks everything, it has to be on every server and in every developer's
config/master.key, and it can't be scoped or revoked per person. The real
weakness of ENV is the .env file. Take away the file and ENV wins on
compatibility, because everything in the Ruby ecosystem already reads it.
Option 1: the Railtie
# Gemfile
gem "seekrit-sdk"
# config/application.rb
module Storefront
class Application < Rails::Application
config.seekrit.autoload = true # load SEEKRIT_TOKEN's secrets into ENV on boot
config.seekrit.override = false # existing ENV wins (default)
end
end
The initializer runs before: :load_environment_config, so anything in
config/environments/production.rb sees the values, and so does
config/database.yml, which ActiveRecord reads when it first connects.
override: false means a variable your platform already sets (a PORT, a
RAILS_ENV) isn't clobbered. Flip it if seekrit should be authoritative.
Remove dotenv-rails from the Gemfile and delete the .env files. Import
them first so nothing is lost:
seekrit secrets import .env.development --app storefront --env development
seekrit secrets import .env.production --app storefront --env production
The token decides which environment loads, so there is no .env.production
versus .env.development logic anywhere in the app. Each server gets a
token bound to its environment and resolves that.
Option 2: wrap the process
If you'd rather keep the SDK out of the app, do it from outside:
seekrit run -- bin/rails server
seekrit run -- bundle exec sidekiq
seekrit run -- bin/rails db:migrate
seekrit run resolves, decrypts locally, puts the values in the child's
environment, and execs it. In development it uses your seekrit login session
and --app/--env flags; in production it uses SEEKRIT_TOKEN. Works for
any process, not just Rails, which is the argument for it on a box that also
runs a cron job or a Node asset build.
Both options put the same values in the same place. Pick the Railtie when you
don't control how the process is launched (a platform's web: process type, a
Procfile), and the wrapper when you do.
Where does SEEKRIT_TOKEN come from?
Locally, you don't need one; seekrit run uses your login session. On a
server, the token is the one variable the host holds, set in whatever the
platform calls its environment settings, or in Kamal's secrets:
# config/deploy.yml
env:
secret:
- SEEKRIT_TOKEN
# .kamal/secrets
SEEKRIT_TOKEN=$(seekrit secrets get SEEKRIT_TOKEN_PRODUCTION --app storefront --env deploy)
Kamal evaluates .kamal/secrets on the deploying machine, so a token stored
in a separate deploy environment in seekrit gets read there and passed to
the containers, and the containers resolve everything else themselves.
A token is bound to one app environment. Mint one per deployment and revoke
it from the dashboard if a server is compromised, which is not something you
can do to a leaked RAILS_MASTER_KEY:
seekrit token create --name web-production --app storefront --env production
Docker
The Ruby image doesn't need the seekrit CLI. Copy the static launcher in and make it the entrypoint, so the container fetches its own secrets at start:
FROM seekritdev/run:latest AS seekrit
FROM ruby:3.3-slim
COPY --from=seekrit /seekrit-run /usr/local/bin/seekrit-run
# ... bundle install, copy app, precompile assets ...
ENTRYPOINT ["seekrit-run", "--"]
CMD ["bin/rails", "server"]
Asset precompilation runs at build time and usually needs no secrets. If a
step does (a private gem source, say), use BuildKit's --mount=type=secret
for that one RUN rather than an ARG, which ends up in image history.
Sidekiq, rake, and the console
Every process that boots Rails boots the Railtie, so Sidekiq workers, rails runner, and rails console all get the environment with no further work. In
production that means a console session on a server can read every value the
token can. That's the same as today with ENV, but it's worth knowing that
the token's environment is the console's blast radius. Give background
workers their own token if they need fewer secrets than the web tier.
Tests
Point the test suite at a test environment, never production:
seekrit run --app storefront --env test -- bin/rails test
Or leave SEEKRIT_TOKEN unset in test and let the Railtie skip, relying on
fixtures and config/database.yml defaults. Either way, a suite that runs on
laptops, in CI, and under coding agents shouldn't be able to decrypt
production.
What about Rails.application.credentials?
Keep using it if you like it. The two coexist; the Railtie only touches
ENV. The case for moving values out of it is operational rather than
cryptographic: per-person and per-server access instead of one master key,
revocation without a deploy, a version history per value, and an audit log of
who read what. If a value is needed by database.yml or a gem's ENV-based
config, it has to be in ENV anyway.
The honest boundary
The value ends up in ENV, where any Ruby code in the process can read it,
including every gem. That's the same boundary a .env file gave you, minus
the file on disk and minus the copy in the repo. Decryption happens in the
Rails process; the seekrit API only ever stores ciphertext, per
the encryption model.
SDK details: Language SDKs. Migrating from a .env:
Replace dotenv.