You deploy Strapi to ECS, Render, Railway, a VPS, or a Docker host. https://cms.example.com serves the Strapi welcome page fine. You open https://cms.example.com/admin and get a blank screen with this in the browser console:
Refused to connect to 'http://localhost:1337/admin/init' because it does not
appear in the connect-src directive of the Content Security Policy.Two things are going on, and only one of them is the actual bug.
What is actually happening
The Strapi admin panel is a static React bundle compiled at build time. When it runs in your browser, it needs to know which URL to send API requests to, and that URL is written into the JavaScript during strapi build. It is not read from the environment at runtime.
If strapi build ran without knowing your public URL, the bundle hardcodes the default http://localhost:1337. Your browser then loads the admin panel from https://cms.example.com and it immediately tries to call http://localhost:1337/admin/init, a machine that, from your browser's point of view, is your own laptop.
The CSP error is the symptom, not the cause. Strapi's security middleware sets a Content-Security-Policy that only permits connections back to itself, so the browser blocks the cross-origin call to localhost and reports it as a CSP violation. Adding localhost:1337 to your CSP would silence the error and change nothing, because the request would still go nowhere.
The fix is to tell Strapi its public URL and rebuild the admin panel.
The fix
1. Set the public URL
In config/server:
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
url: env('PUBLIC_URL', 'http://localhost:1337'),
app: {
keys: env.array('APP_KEYS'),
},
});And in your deployed environment:
PUBLIC_URL=https://cms.example.comNote host: '0.0.0.0', not localhost. Inside a container, binding to localhost means the process only accepts connections from inside that container, so the health check fails and the proxy gets connection refused. This is a separate, equally common deployment mistake.
2. Rebuild the admin panel with that variable present
This is the step people miss:
NODE_ENV=production PUBLIC_URL=https://cms.example.com npm run build
NODE_ENV=production npm run startPUBLIC_URL must be set during the build, not only when the server starts. Setting it at runtime alone changes nothing, because the bundle was already compiled with the old value.
In a Dockerfile:
ARG PUBLIC_URL
ENV PUBLIC_URL=${PUBLIC_URL}
ENV NODE_ENV=production
RUN npm run build
CMD ["npm", "run", "start"]Pass it in at build time:
docker build --build-arg PUBLIC_URL=https://cms.example.com -t my-strapi .In the original forum thread, the poster's Dockerfile was starting Strapi without ever running npm run build in the image. Adding the build step resolved it, which is exactly this problem: the running container was serving a stale admin bundle built with default values.
3. Check config/admin if the admin lives elsewhere
By default the admin panel is served at /admin on the same host. If you serve it from a different path or a different domain, set it explicitly:
export default ({ env }) => ({
url: env('ADMIN_URL', '/admin'),
auth: {
secret: env('ADMIN_JWT_SECRET'),
},
});Changing anything under config/server or config/admin requires a rebuild. The docs are explicit about this: "Changes to the server.js file require rebuilding the admin panel."
The other cause: a port mismatch
The second reply in the original thread hit a different version of the same problem. The PORT environment variable was set to something (8080, or 80), but the admin bundle had been built expecting 1337, so the UI was served on one port while its API calls went to another.
Keep them consistent. If your platform injects a PORT variable, as Heroku, Cloud Run, Render, and Railway all do, read it rather than hardcoding:
port: env.int('PORT', 1337),And set url to the public URL your users hit, which is usually port 443 with no port in the string, not the internal port the container listens on. Those two values are different things and conflating them is a frequent source of this exact error.
When you genuinely do need to touch CSP
Adding hosts to connect-src is the right move when the admin panel legitimately needs to talk to another origin, most commonly a third-party media provider, where images and videos are served from S3 or Cloudinary rather than from Strapi:
export default [
'strapi::logger',
'strapi::errors',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'connect-src': ["'self'", 'https:'],
'img-src': ["'self'", 'data:', 'blob:', 'market-assets.strapi.io', 'my-bucket.s3.eu-west-1.amazonaws.com'],
'media-src': ["'self'", 'data:', 'blob:', 'market-assets.strapi.io', 'my-bucket.s3.eu-west-1.amazonaws.com'],
upgradeInsecureRequests: null,
},
},
},
},
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];Keep market-assets.strapi.io, which is a default that the in-app Marketplace needs.
Deployment checklist
hostis0.0.0.0, notlocalhost.portreads fromenv.int('PORT', 1337).urlinconfig/serveris your public HTTPS URL.- That variable is present during
strapi build, not just at runtime. NODE_ENV=productionis set for both build and start.- The image or release artifact actually contains a fresh admin build.
- If you are behind a reverse proxy, set
proxy.koa: trueinconfig/server. - Verify the server is up independently of the admin panel:
curl -I https://cms.example.com/_healthshould return204.




