seekrit
← all posts

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.encENV
Encrypted at rest in the repoYesNo (a .env is plaintext)
Readable by gems and database.ymlOnly via Rails.application.credentialsYes, directly
Per-environment valuescredentials/production.yml.enc etc.Whatever is set at boot
Who can decryptAnyone with RAILS_MASTER_KEYAnyone who can read the process or the file
RotationEdit, commit, deployChange 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.