Favicon Setup for Ruby on Rails — Free, Fast & Private
The split above is deliberate, not indecision. Assets under app/assets/images get a content digest in their filename, which is exactly what you want for cache headers set to a year — change the image and the URL changes with it. But /favicon.ico is a path browsers hit by convention with no tag to guide them, and a digested filename cannot satisfy a hardcoded request. So the .ico lives in public/, undigested, and everything else goes through the pipeline.
Where each generated file goes in Ruby on Rails
The generator above emits seven files with fixed names. Static assets in Ruby on Rails live in app/assets/images/ or public/, and the markup is written in app/views/layouts/application.html.erb. This is the mapping between the two.
| Generated file | Goes to | Notes |
|---|---|---|
| favicon.ico | public/favicon.ico | Must be undigested at the root — browsers request this literal path. |
| favicon-16x16.png | app/assets/images/favicon-16x16.png | Served fingerprinted via favicon_link_tag. |
| favicon-32x32.png | app/assets/images/favicon-32x32.png | Served fingerprinted via favicon_link_tag. |
| favicon-48x48.png | app/assets/images/favicon-48x48.png | Optional — the .ico carries this size. |
| apple-touch-icon.png | app/assets/images/apple-touch-icon.png | Fingerprinted; some older iOS versions probe the root, so a public/ copy is cheap insurance. |
| android-chrome-192x192.png | app/assets/images/android-chrome-192x192.png | Referenced from the manifest via image_path. |
| android-chrome-512x512.png | app/assets/images/android-chrome-512x512.png | Referenced from the manifest via image_path. |
favicon_link_tag and what it defaults to
Rails ships a dedicated helper for this. favicon_link_tag takes a source, defaults it to "favicon.ico", resolves it through the asset pipeline, and emits a link element. Its default options are rel: "icon" and type: "image/x-icon", which are right for an .ico and wrong for a PNG — pass type: "image/png" explicitly for every PNG entry or you will declare a MIME type that does not match the bytes.
The other option worth passing is rel: "apple-touch-icon" for the 180px file, since the helper will otherwise mark it as a regular icon. Everything you pass beyond the recognised options becomes an HTML attribute, so sizes works the way you would hope.
The reason to use the helper rather than writing raw link tags is the asset host and digest handling that comes with it. Set config.asset_host to a CDN and every helper-generated URL picks it up with no further edits; hand-written paths do not. In a Rails app that already serves assets from a CDN, hardcoding /favicon-32x32.png quietly bypasses it.
app/views/layouts/application.html.erb
<head> <title><%= content_for(:title) || "Your App" %></title> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= favicon_link_tag "favicon-32x32.png", rel: "icon", type: "image/png", sizes: "32x32" %> <%= favicon_link_tag "favicon-16x16.png", rel: "icon", type: "image/png", sizes: "16x16" %> <%= favicon_link_tag "apple-touch-icon.png", rel: "apple-touch-icon", type: "image/png", sizes: "180x180" %> <link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48"> <%= tag.link rel: "manifest", href: site_manifest_path %> <%= stylesheet_link_tag :app %> <%= javascript_importmap_tags %> </head>
Propshaft and Sprockets disagree about manifests
Rails 8 defaults to Propshaft, which indexes every file under the configured asset paths and needs no declaration step. Drop a PNG into app/assets/images and it is immediately resolvable. Sprockets, which Rails 7 and earlier used and which plenty of applications still run, works the opposite way: an asset must be reachable from app/assets/config/manifest.js before it will be compiled into public/assets in production.
The stock manifest.js has a link_tree line covering the images directory, so a fresh app is fine. Applications that have been through a few upgrades are frequently not — someone trimmed that file, and the result is a favicon that resolves perfectly in development, where Sprockets compiles on demand, and raises Sprockets::Rails::Helper::AssetNotPrecompiled in production. If your icons work locally and blow up after deploy, open that file first.
On either pipeline, remember that precompilation happens at deploy time. Adding an image and restarting the server is not enough in production; the assets:precompile task has to run.
app/assets/config/manifest.js — Sprockets only
//= link_tree ../images //= link_directory ../stylesheets .css //= link_tree ../../javascript .js
The manifest, and the tags Rails already generated
A modern Rails scaffold writes icon link tags into the generated layout for you, pointing at files it placed in public/. Those are placeholders. Delete them when you add your own, or you will ship two competing sets of icon declarations and the browser will pick whichever it likes — usually not yours.
For the web app manifest, an ERB view rendered by a small controller action lets you use image_path so the manifest points at the same digested URLs as everything else. Serve it as application/manifest+json, add a route, and reference it from the layout. A static file in public/ works too, but then the manifest icon URLs bypass the digest and the asset host, and drift out of sync with the rest of your assets on the next redeploy.
app/views/site/manifest.json.erb
{
"name": "Your App",
"short_name": "App",
"start_url": "/",
"display": "standalone",
"icons": [
{ "src": "<%= image_path('android-chrome-192x192.png') %>",
"sizes": "192x192", "type": "image/png" },
{ "src": "<%= image_path('android-chrome-512x512.png') %>",
"sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}Turbo, caching, and one layout to rule them all
Turbo Drive replaces the body between visits and merges the head rather than reloading the document, so the icon declarations from your layout persist across an entire browsing session. One edit to application.html.erb covers every page the layout wraps. Watch for applications with more than one layout — an admin namespace or a marketing layout is a common place for the icons to be quietly missing.
The digested URLs let you serve those PNGs with a one-year immutable cache header safely, since a new image means a new filename. public/favicon.ico is the exception with no digest to protect it, so give that path a shorter max-age. It is a small file requested constantly, and a week of caching costs nothing while a year of it means your rebrand is invisible to returning users for a very long time.
Whatever you paste, the artwork itself never leaves your machine. The set above is rendered with the Canvas API and the .ico container is assembled in JavaScript on your device, so an unreleased logo stays unreleased. If the 16px preview looks like mush, that is the honest signal to simplify the mark before it ships to a Ruby on Rails tab near you.
