A distinct favicon color per environment, so you never deploy to the wrong tab.
EnvStyle is a mountable Rails engine that tints your app's favicon a different color in development, staging, and preview — while leaving production untouched. It's the Rails equivalent of env.style, but it runs at request time instead of build time, and it sits below your front-end framework, so it works identically in Hotwire and Inertia apps and depends on neither.
- The Problem
- The Solution
- Requirements
- Installation
- Quick Start
- Usage
- Configuration
- How It Works
- Operational Notes
- Troubleshooting
- Development
- Contributing
- License
You have five tabs open: production, staging, a preview branch, and two local servers. They all render the same favicon. You run a destructive rake task, submit a form, or click "Delete" — in the wrong tab. Every tab looks the same, so the only defense is reading the URL every single time.
env.style solves this beautifully for build-time front-ends, but it has two gaps for a Rails app:
- It restyles at build time, so a build promoted from staging to production keeps its staging tint (see rails/rails#44211 for the friction this causes).
- It's coupled to a JS build pipeline, which doesn't map cleanly onto a server-rendered Rails app that may or may not use Hotwire, Inertia, or a bundler at all.
EnvStyle serves the favicon from a route it owns and tints it when the request comes in, based on the environment resolved at that moment. A build promoted to production re-resolves and serves the untouched icon — no rebuild, no stale tint. Because the whole mechanism is a server route plus one <link> tag in your layout <head>, it is completely framework agnostic: no JavaScript, no coupling to Turbo, Stimulus, Inertia, or Vue.
- Runtime, not build time. The environment is resolved per request.
- Production untouched. Production is never tinted and does zero image work.
- Framework agnostic. A route and a
<link>tag — that's the entire integration surface. - Zero-config. With no initializer, it auto-discovers your icon and uses a built-in color map. Adding the helper is the only required step.
- Ruby >= 3.2
- Rails >= 7.2 (
railties,actionpack,activesupport) - libvips — only for tinting raster icons (PNG/ICO). SVG icons need no system dependency. libvips already ships in the default Rails 8 Dockerfile (it is Active Storage's default variant processor), so most modern apps already have it.
Add the gem to your Gemfile:
gem "env_style"Install it:
bundle installIf you serve a raster favicon (PNG/ICO), install libvips (skip this for SVG-only apps):
# macOS
brew install vips
# Debian / Ubuntu
apt-get install -y libvipsMount the engine in config/routes.rb:
Rails.application.routes.draw do
mount EnvStyle::Engine => "/env_style"
endThree snippets and you're done. First, the Gemfile (above). Then mount the engine (above). Then add the helper to your layout <head> — the same line whether you use Hotwire or Inertia:
<%# app/views/layouts/application.html.erb %>
<head>
<%= env_style_favicon_tags %>
</head>That's it. With no initializer, EnvStyle auto-discovers public/icon.svg (or public/icon.png, public/favicon.svg, public/favicon.ico), tints it blue in development and amber in staging/preview, and leaves production untouched.
The environment name is resolved in this order (mirroring env.style):
config.environment(explicit override)ENV["ENV_STYLE_ENV"]Rails.env
The resolved name is mapped to a color via config.colors, falling back to default_color for any non-production environment that isn't listed. production is never tinted, and a nil default_color leaves unknown environments untouched too.
The built-in map covers the common environments:
{
"development" => "#3b82f6", # blue
"staging" => "#f59e0b", # amber
"preview" => "#f59e0b" # amber
}Override or extend it in the initializer (see Configuration). Any CSS color string works for SVG sources; raster sources require a hex color.
You never need the icon's public URL — only its bytes on disk — so its location and asset pipeline are irrelevant. Resolution has two tiers:
Auto-discovery (default). The first existing path wins, preferring SVG:
public/icon.svg → public/icon.png → public/favicon.svg → public/favicon.ico
Explicit config.source (override). A Rails.root-relative path (string or Pathname) always wins, for any non-conventional name or location:
c.source = Rails.root.join("app/frontend/images/logo.svg")The path must resolve within Rails.root — absolute paths under the root are fine, but a path that escapes it (via .. or an unrelated absolute path) is ignored, so deriving source from untrusted input can't be used to read arbitrary files.
Pass a hash to use a different icon per environment (like env.style). Environments absent from the hash fall back to auto-discovery:
c.source = {
"development" => "public/icon-dev.svg",
"staging" => "public/icon-staging.png"
}Raster icons are recolored with a luminance-preserving tint (shading is kept; the hue shifts to the environment color), with alpha preserved. Colors listed in exclude_colors are left exactly as they are — handy for keeping a white background white:
c.exclude_colors = ["#ffffff"]An exact-match exclusion can leave a faint tinted halo around the mark, because the anti-aliased edge pixels next to (say) a white background aren't exactly white and so still get tinted. Widen the match with exclude_tolerance — the per-channel sRGB distance (0–255) a pixel may sit from an excluded color and still be preserved. It defaults to 0 (exact match only); a small value such as 8 also keeps the near-matching halo:
c.exclude_colors = ["#ffffff"]
c.exclude_tolerance = 8By default, SVG sources stay SVG (crisp, scalable, no system dependency). If you specifically want a tinted PNG rendered from an SVG source, opt in:
c.rasterize_svg = trueThe PNG is rendered at rasterize_size pixels per edge (default 32) rather than the SVG's intrinsic size, so a small viewBox still yields a crisp favicon on hi-dpi tabs. Bump it for sharper output:
c.rasterize_size = 64This path uses libvips' SVG loader, which requires a libvips built with librsvg.
enabled is the master switch. When it's false, env_style_favicon_tags emits nothing, so no favicon <link> is added to your pages. (The mounted route still responds if requested directly, streaming the source untouched — but nothing links to it.)
c.enabled = !Rails.env.production?An initializer is entirely optional. When you want one:
# config/initializers/env_style.rb
EnvStyle.configure do |c|
c.enabled = true # master switch
c.source = Rails.root.join("public/icon.svg") # optional; auto-discovered otherwise
c.colors = { "development" => "#3b82f6",
"staging" => "#f59e0b",
"preview" => "#f59e0b" }
c.default_color = "#6b7280" # unknown non-prod environments
c.exclude_colors = ["#ffffff"] # raster: preserve these pixels
c.exclude_tolerance = 0 # raster: per-channel sRGB match slack
c.environment = ENV["ENV_STYLE_ENV"] # optional environment override
c.rasterize_svg = false # opt-in: render SVG → PNG via libvips
c.rasterize_size = 32 # edge length (px) of the rasterized PNG
end| Option | Default | Purpose |
|---|---|---|
enabled |
true |
Master switch; false stops the helper from emitting a <link> (the route still responds if hit directly). |
source |
nil (auto-discover) |
Icon path, Pathname, or per-environment hash. |
colors |
built-in map | Environment name → tint color. |
default_color |
"#6b7280" |
Tint for non-production environments not in colors; nil to skip them. |
exclude_colors |
[] |
Raster pixels to preserve verbatim. |
exclude_tolerance |
0 |
Per-channel sRGB distance (0–255) a raster pixel may sit from an exclude_colors entry and still be preserved; 0 matches exactly, a small value keeps the anti-aliased halo. |
environment |
nil |
Explicit environment override (highest precedence). |
rasterize_svg |
false |
Render SVG sources to tinted PNG via libvips. |
rasterize_size |
32 |
Edge length in pixels of the rasterized PNG (only when rasterize_svg is true); larger renders crisper on hi-dpi tabs. |
logger |
Rails.logger |
Where the "no source icon found" warning is written. |
env_style_favicon_tags renders a <link> that points at the engine route (/env_style/favicon.svg or /env_style/favicon.png, chosen from the resolved source). When the browser requests it, EnvStyle::FaviconsController resolves the environment and either tints the source or — for production and any untouched environment — streams the source bytes verbatim.
- SVG is tinted by rewriting markup: a monochrome mark has its
fill/currentColorswapped; a multicolor mark is wrapped in a group carrying a luminance-preserving<feColorMatrix>duotone filter. No image library involved. - Raster is tinted with libvips: luminance scales the tint per pixel, alpha is carried through, and
exclude_colorspixels are masked back to their originals.
Tinting happens on demand and is deterministic in (source digest, environment, color, format) — plus exclude_colors for raster output. Rather than keep a server-side cache coherent across workers, EnvStyle leans on HTTP: every response carries a strong ETag over that tuple and Cache-Control: no-cache, must-revalidate. Browsers and proxies cache the icon and revalidate with a conditional request, which the controller answers with a 304 Not Modified before doing any image work — so a favicon is tinted at most once per client cache-miss, and a promoted deploy always re-resolves the environment instead of serving a stale-env icon.
The mechanism sits at the Rails/document layer, below your front-end framework:
- Hotwire. A plain server route plus a layout
<head>tag. Turbo never touches it. - Inertia. The
<link>renders in the root ERB layout on the initial full-page load and persists across client-side visits (the document is never reloaded). The/env_style/favicon.*request carries noX-Inertiaheader, so Inertia's middleware passes it straight through, and SSR is unaffected because the root layout<head>is still Rails-rendered. Caveat: if your app sets its favicon via Inertia's<Head>component, manage it in one place — a client-side favicon set there would override this one.
- No-dependency, SVG-only. Rejected as the sole strategy: Safari has historically cached and mishandled SVG favicons, and many apps ship raster icons. EnvStyle handles SVG with zero dependencies but also supports raster.
- Pure-Ruby raster gems.
chunky_pngis PNG-only;pura-imagecan't recolor. Neither covers the raster tinting requirement. - Runtime-detect-with-squircle fallback. Rejected as non-deterministic across machines.
- libvips. Chosen as the one robust raster backend. It already ships with modern Rails (the default Rails 8 Dockerfile, Active Storage's default variant processor), and the dependency is stated upfront so it is never a surprise.
- Production does zero image work. Production is untouched — the controller streams the source bytes with no tinting.
- Performance. Repeat requests from a client that already has the icon short-circuit to a
304 Not Modifiedbefore any tinting, so image work happens only on a genuine cache-miss — a few milliseconds for a small icon. There is no server-side cache to grow or invalidate. - libvips. Required only for raster tinting. SVG-only apps never load it (
ruby-vipsis required lazily). - Source changes. Because the ETag includes the source digest, editing the source icon produces a new validator and a freshly tinted result on the next request.
- Scope. v1 tints the favicon only — no apple-touch or PWA icon variants.
env_style: no source icon found — set EnvStyle.config.source— no icon was auto- discovered. Addpublic/icon.svg(or.png), or setconfig.source. The helper emits nothing and nothing crashes.- The favicon isn't tinted in development. Confirm the engine is mounted, the helper is in your layout
<head>, and the resolved environment has a color (it isn'tproduction, anddefault_colorisn'tnil). - A pure-black raster (PNG/ICO) icon barely changes color. Raster tinting is luminance-preserving, so a pixel with zero luminance stays dark for any tint. Prefer an SVG source (the default) or a non-pure-black raster for a visible tint.
Vips::Error/ "class "svgload" not found" withrasterize_svg = true. Your libvips was built without librsvg. Install a librsvg-enabled libvips, or leaverasterize_svgfalseand serve tinted SVG.LoadError: cannot load such file -- vips— install libvips (see Installation); it is only needed for raster icons.
bin/setup # install dependencies
bundle exec rake # run the tests and RuboCopTests run against a bare Rails app under test/dummy that mounts the engine and includes both a Hotwire-style and an Inertia-style layout. To exercise every supported Rails version:
bundle exec appraisal install
bundle exec appraisal rake testWith mise installed, mise run test and mise run appraisal wrap the above.
Bug reports and pull requests are welcome at https://github.com/milkstrawai/env_style. Please add tests for any change and keep bundle exec rake green.
Released under the MIT License.
