TL;DR — Moving a self-hosted CMS onto shared hosting behind a reverse proxy broke two independent assumptions at once: local file storage isn't actually persistent across deploys, and origin/CSRF checks and signed URLs need the app's real public site URL told to it explicitly rather than inferred from the request.
An uploaded image displays fine immediately, then 404s after the app redeploys. Local storage's default upload flow writes relative to the app's working directory — which gets replaced wholesale on each deploy on shared hosting, so nothing written there actually persists.
// custom storage adapter -- signs uploads to a dedicated API route
// writing to an absolute path outside the build directory
const UPLOADS_DIR = process.env.UPLOADS_DIR; // e.g. /home/user/persistent-uploads
// ...write file to UPLOADS_DIR, serve it back via its own signed-URL route
emdashkits.com
Gotcha 2 — CSRF/origin checks and signed URLs misbehave behind the proxy
With origin-checking enabled, and the app sitting behind a reverse proxy, it needs to be told its real public-facing URL explicitly rather than inferring it from the request's own host header — otherwise origin/CSRF validation and signed upload URLs can resolve against the wrong host.
// astro.config.mjs
export default {
security: { checkOrigin: true },
// + an explicit site URL / EMDASH_SITE_URL env var set to the real
// public domain, not inferred from the proxied request
};
Shared/proxied hosting breaks two independent assumptions at once: that local disk persists, and that a request's own host header is trustworthy. Budget for both when migrating a self-hosted CMS off a VM onto shared hosting.
Test an upload, then trigger a redeploy, then check the upload again — persistence bugs like this don't show up until the second deploy.
TL;DR — A custom session-cookie login flow appeared to succeed on localhost (the OTP verified, the response looked fine) but every subsequent request to a login-gated page treated the visitor as logged out. Identical code worked fine on the live HTTPS site. The cookie's Secure attribute was hardcoded to true — and per the cookie spec, browsers never store or send a Secure cookie over a plain, non-HTTPS connection.
Check the actual Set-Cookie response header and the browser's own cookie storage panel — on localhost over http://, the cookie is sent by the server but never actually stored by the browser.
Root cause
// before -- assumes the app is always served over HTTPS
setCookie("session", token, { secure: true, httpOnly: true });
emdashkits.com
A cookie config that quietly assumes "we're always on HTTPS" breaks the instant you test over plain HTTP, which local dev servers commonly are.
// after -- derive secure from the actual request protocol
const isHttps = request.url.startsWith("https://");
setCookie("session", token, { secure: isHttps, httpOnly: true });
emdashkits.com
Lessons learned
Any Secure-flagged cookie needs to key off the real request scheme, not an assumption baked in once at cookie-creation time.
"Works in production, silently fails in local dev" is a strong signal to check cookie flags before anything else in an auth flow.
Check other cookies in the same codebase for the same hardcoded assumption — if one cookie has this bug, sibling cookies set the same way are worth auditing too.
TL;DR — GA4's gtag.js snippet was installed correctly, the page loaded with no visible JavaScript error, and GA4's real-time report still showed zero activity. The CMS's default Content-Security-Policy had no allowance for analytics domains and no config option to add one — so every request to Google's tracking endpoints was blocked at the browser level before it could fail loudly.
Check the browser's dedicated CSP violation reporting, not the regular console error list — CSP blocks are reported through their own channel, not thrown as normal script errors, so "no console errors" doesn't mean nothing was blocked.
Root cause
The CSP's script-src and connect-src directives had no entry for googletagmanager.com or google-analytics.com, and the CMS exposed no configuration surface to add one — the only way in was patching the CSP directives directly.
// patch-package: add analytics domains to the existing CSP directives
scriptSrc.push("https://www.googletagmanager.com");
connectSrc.push("https://www.google-analytics.com", "https://www.googletagmanager.com");
"No console errors" is not proof nothing was blocked — CSP violations live in their own reporting surface and are easy to miss if you're only scanning for red error text.
Before adding any third-party script tag to a site with a CSP already in place, check the CSP's directives first rather than assuming a silently-empty analytics dashboard means a snippet-installation mistake.
Comments