diff --git a/backend/server.js b/backend/server.js
index afb377e7..cfd08923 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -559,6 +559,34 @@ app.get('/robots.txt', async (req, res) => {
}
});
+// Dynamic favicon endpoints. Browsers — notably Safari — request
+// /favicon.ico and /apple-touch-icon*.png directly at the site root and are
+// unreliable about honouring JS-injected tags. Serving the
+// admin's configured branding favicon here makes it work without client-side
+// JS (and survive aggressive favicon caches). Falls back to the bundled asset
+// shipped with the frontend build when no custom favicon is set.
+app.get(
+ ['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png'],
+ async (req, res) => {
+ try {
+ const { getAppSetting } = require('./src/utils/appSettings');
+ const raw = await getAppSetting('branding_favicon_url', null);
+ const url = (raw && String(raw).trim()) || null;
+ if (url) {
+ // Absolute URL → redirect as-is. Otherwise it's an /uploads path the
+ // backend already serves with the correct content-type + headers.
+ const target = /^https?:\/\//i.test(url)
+ ? url
+ : (url.startsWith('/') ? url : `/uploads/${url.replace(/^uploads\//, '')}`);
+ return res.redirect(302, target);
+ }
+ } catch (error) {
+ logger.warn('Favicon lookup failed; serving bundled default', { error: error.message });
+ }
+ return res.redirect(302, '/favicon-32x32.png');
+ }
+);
+
// Health check endpoint. `pid` + `uptime` let monitors (and the local E2E
// watchdog) detect a silent process restart between two checks.
app.get('/health', async (req, res) => {
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 1c8dc721..45175a03 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -184,6 +184,40 @@ server {
proxy_set_header X-Forwarded-Proto $real_proto;
}
+ # Dynamic favicon / apple-touch-icon served by backend (resolves the
+ # admin-configured branding favicon, falls back to the bundled asset).
+ # Exact-match (=) wins over the static-asset regex below, so these reach
+ # the backend instead of the build dir. Browsers (especially Safari)
+ # request these at the site root regardless of any JS-injected
+ # .
+ location = /favicon.ico {
+ set $backend_upstream backend;
+ proxy_pass http://$backend_upstream:3000/favicon.ico;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $real_proto;
+ }
+ location = /apple-touch-icon.png {
+ set $backend_upstream backend;
+ proxy_pass http://$backend_upstream:3000/apple-touch-icon.png;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $real_proto;
+ }
+ location = /apple-touch-icon-precomposed.png {
+ set $backend_upstream backend;
+ proxy_pass http://$backend_upstream:3000/apple-touch-icon-precomposed.png;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $real_proto;
+ }
+
# Delegate root requests to backend for public landing page handling
location = / {
# Use variable to force DNS resolution per request (required for Docker Swarm)
diff --git a/frontend/src/components/common/DynamicFavicon.tsx b/frontend/src/components/common/DynamicFavicon.tsx
index 1cade38f..c5686106 100644
--- a/frontend/src/components/common/DynamicFavicon.tsx
+++ b/frontend/src/components/common/DynamicFavicon.tsx
@@ -35,8 +35,16 @@ export const DynamicFavicon: React.FC = () => {
link.rel = 'icon';
if (ext && typeByExt[ext]) link.type = typeByExt[ext];
link.href = href;
-
document.head.appendChild(link);
+
+ // Safari uses apple-touch-icon for bookmarks / home-screen and is
+ // unreliable about JS-injected rel="icon". The backend /favicon.ico +
+ // /apple-touch-icon routes are the primary mechanism; this is
+ // belt-and-braces for browsers that do read the DOM link.
+ const appleLink = document.createElement('link');
+ appleLink.rel = 'apple-touch-icon';
+ appleLink.href = href;
+ document.head.appendChild(appleLink);
}
}, [settings?.branding_favicon_url]);