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 eight files with fixed names, site.webmanifest included. 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. |
| site.webmanifest | app/views/site/manifest.json.erb (see below) | The one framework here where the downloaded file is a starting point, not a drop-in — digested icons live at a different path from the manifest. |
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.
Rails is the one framework in this cluster where the site.webmanifest the generator downloads is a starting point rather than a drop-in, and it is worth saying why rather than letting you find out. That file uses relative icon paths, which browsers resolve against the manifest's own URL — a deliberate choice that makes it correct on a root deploy and a subpath deploy alike, and which depends on the manifest sitting in the same directory as the icons. Rails is the arrangement that breaks the assumption: the PNGs are digested under /assets/, the manifest is rendered from a route somewhere else entirely, and a relative src would resolve against the route rather than the asset directory. So take the structure from the downloaded file and let image_path write the two URLs, as below. If you would rather ship it as a plain public/site.webmanifest, that works unchanged — but then put the two android-chrome PNGs in public/ beside it instead of in app/assets/images, and accept that those two lose their digest.
app/views/site/manifest.json.erb — the downloaded file with image_path writing the two URLs
{
"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" }
]
}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.
