Safeguard
Container Security

Securing a Dockerized Rails Local Dev Environment

A misplaced master.key or a permissive COPY . . can bake Rails credentials into an image layer forever — here's how to Dockerize Rails dev safely.

Safeguard Research Team
Research
6 min read

Rails has shipped encrypted credentials since version 5.2, splitting config/credentials.yml.enc (safe to commit, ciphertext) from config/master.key (gitignored by default, the only thing that decrypts it). That split is good design — until a Dockerfile with a bare COPY . . and no .dockerignore picks up an untracked master.key sitting in the working directory and bakes it straight into an image layer, where it persists in docker history even after a later RUN rm tries to delete it. This is not a hypothetical: it's the same class of mistake that makes container images one of the most common places static and git-history secret scanners find live credentials today. Add to that a second everyday failure mode — Linux bind mounts creating host files owned by a container UID that doesn't match your login, which pushes developers toward chmod -R 777 . as a "fix" — and a Rails docker-compose.yml written for convenience quietly becomes a liability. This post walks through the three places a Dockerized Rails dev setup typically leaks: the build context, the secrets path, and the volume permission model, and what to do instead at each one.

Why does COPY . . put Rails credentials at risk?

COPY . . (or ADD . .) sends your entire build context to the Docker daemon and copies all of it into the image unless .dockerignore says otherwise, and a bare Rails checkout commonly contains far more than the daemon needs: .git/ (full history, including anything ever committed and later "removed"), tmp/, log/, node_modules/, and — critically — any untracked .env file or a config/master.key a developer generated locally and forgot was gitignored rather than daemon-ignored. Git ignoring a file only keeps it out of commits; it does nothing to keep it out of a Docker build context. Once a secret lands in an intermediate layer, deleting it in a later RUN instruction does not remove it — every layer is content-addressed and cached, so the plaintext key is still extractable from the image with docker save and a layer-by-layer tar inspection, even from an image tagged and pushed months ago. The fix is a .dockerignore that explicitly excludes .git, .env*, config/master.key, tmp/, log/, and node_modules/, checked in alongside the Dockerfile itself so it isn't optional per-developer hygiene.

What's the safer way to hand a Rails container its master key?

The safer pattern is RAILS_MASTER_KEY as an environment variable injected at container start, not a key file copied into the image — but even that has a caveat worth knowing. Rails reads RAILS_MASTER_KEY from the environment specifically so containers don't need the key file on disk at all; that's the documented, supported path for decrypting credentials.yml.enc in any non-development environment. The catch is that classic Compose environment:/.env values are visible to anyone who can run docker inspect on the container or read /proc/<pid>/environ on the host, and they show up routinely in crash reports and error-tracker breadcrumbs that capture the process environment. The Compose Specification's native secrets: block is the better fit for local dev: a file:-sourced secret is mounted read-only at /run/secrets/<name> instead of sitting in the environment table, so an entrypoint script can read the file and export RAILS_MASTER_KEY into the Rails process without the value ever appearing in docker inspect output. It's a small structural change — one secrets: stanza and one file read in the entrypoint — for a real reduction in where the key can leak from.

Why do bind-mounted Rails directories end up owned by the wrong user?

This happens because a container's filesystem UID namespace and your host login's UID are two different numbering systems that only agree by coincidence. Most Ruby base images either run as root by default or define an app user at a fixed UID like 1000; when that process creates files in a bind-mounted directory — tmp/cache, log/development.log, .bundle, anything bundle install or rails generate writes — the files show up on the host owned by that container UID. If it doesn't match your host user (common on Linux; usually invisible on Docker Desktop for Mac/Windows because of its VM-mediated bind mount), you get files your host user can't edit or delete without sudo. The well-worn "fix" is chmod -R 777 on the app directory, which makes the immediate error go away by making every file world-writable — including config/, db/, and anything else in the bind mount — for as long as the directory exists on that host. The durable fix is to build the container's app user with a UID/GID that matches the host developer, commonly passed as build arguments (--build-arg UID=$(id -u)) so the image itself, not a chmod after the fact, keeps ownership aligned.

What should a .dockerignore for a Rails app actually contain?

It should exclude everything the build doesn't need and everything that could contain a secret, not just the obvious node_modules. A baseline Rails .dockerignore excludes .git, .env, .env.*, config/master.key, config/credentials/*.key, tmp/, log/, storage/ (Active Storage local files), .bundle/, and vendor/bundle if you vendor gems — mirroring, deliberately, most of what's already in a default .gitignore, because the build context and the git tree are two independent inclusion lists that happen to need the same exclusions for different reasons. Teams that only maintain a .gitignore and assume Docker respects it are the ones most likely to ship a master.key in an image layer, because nothing in the Docker build pipeline consults .gitignore at all.

How does image and registry hygiene fit into local Rails dev?

Local dev images matter for supply-chain security even though they never reach production, because the base images pulled for Postgres, Redis, and Ruby itself are as much a trust boundary as any application dependency. Pinning FROM ruby:3.3-slim (or whatever version) by digest rather than a floating tag, and periodically re-pulling and re-scanning cached local images, catches both known CVEs in the base OS packages and — less obviously — any secret a teammate's build accidentally baked in and pushed to a shared internal registry for "just testing locally." A docker-compose.yml checked into the repo effectively documents which images every contributor's machine will pull, which makes it a legitimate place to enforce digest pinning as a team policy rather than leaving it to individual discipline.

How Safeguard Helps

Safeguard's secrets scanning extracts and inspects every layer of a container image, not just the final filesystem state, so a master.key or .env value baked in by a stray COPY . . and later "deleted" in a subsequent layer is still caught — the same way it catches secrets buried in Git history rather than just the current HEAD. Verified findings for supported issuers are confirmed live against the issuing service before they're raised as critical, so a leaked Rails credential isn't just flagged as a pattern match — it's confirmed to actually work. For the build-context version of this problem, safeguard install-hook --hook pre-push runs the same detection locally before a commit with an accidentally-included master.key or .env ever leaves a developer's machine, closing the gap that .dockerignore alone can't cover once someone forgets to update it.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.