🐳 I spent an afternoon chasing a deceptively simple goal: edit a stylesheet in a custom Moodle theme, hit refresh, and see the change — without running a full production build every time. The tool that promises exactly this is Vite’s Hot Module Replacement (HMR): a dev server that pushes code changes into the running page instantly, no rebuild and often no reload. Getting it working turned what looked like a one-line CSS fix into a tour through Vite’s dev server, self-signed certificates, and a reverse proxy I almost dragged into the mess for no reason. Here’s the whole adventure, with the technical detail that actually mattered.
💡 Quick glossary for the acronyms ahead: SCSS is Sassy CSS, the stylesheet language that compiles down to CSS; HMR is the live-reload mechanism above; TLS is Transport Layer Security, the encryption behind HTTPS; and CORS is Cross-Origin Resource Sharing, the browser rule about which origins may talk to each other. Each is expanded again where it first matters below.
The stack
The local environment runs Moodle 4.5 on the official moodle-docker stack (MariaDB + PHP 8.3 in containers, the Moodle web root bind-mounted into the webserver container). On top of that, a local Caddy instance plus an /etc/hosts entry maps a friendly HTTPS hostname — call it dev.example.test — to the container’s port 8000, terminating TLS (Transport Layer Security, the encryption behind HTTPS) with a locally-trusted certificate. So https://dev.example.test/ and http://localhost:8000/ are two front doors to the exact same Docker instance, same database, same code. That detail becomes important later. 🔑
Why editing SCSS did nothing
First surprise: I edited a SCSS partial, purged Moodle’s caches, refreshed — and nothing changed. The reason is that this theme opts out of Moodle’s built-in SCSS compilation entirely and ships a pre-built stylesheet instead. The theme’s config.php does exactly that:
1 2 3 4 5 6 7 8 | // Disable built-in SCSS compilation and use our own generated stylesheet. $THEME->scss = fn($theme) => ''; if (!empty($CFG->env) && $CFG->env === ENV_LOCAL) { $THEME->javascripts_footer = ['hmr']; // inject the Vite HMR loader } else { $THEME->sheets = ['app']; // serve the prebuilt style/app.css } |
Because Moodle never compiles the SCSS, purging caches accomplishes nothing — there’s nothing to recompile. The browser was being served a stale style/app.css baked by a previous Vite production build. Confirming this was simple: grep the built CSS for the rule I’d just changed, and sure enough, it still held the old declaration. The fix isn’t a cache purge — it’s getting the live Vite dev server in front of the page so HMR can take over.
The ENV_LOCAL trap
Notice the branch above hinges on $CFG->env === ENV_LOCAL. Set that flag and the theme injects an hmr.js loader instead of the static stylesheet. The catch: ENV_LOCAL wasn’t a Moodle core constant — it was defined by a plugin that happened to be disabled in this environment. Writing $CFG->env = ENV_LOCAL; straight into the config would have thrown an “Undefined constant” fatal. So I defined it defensively in config.php before Moodle’s setup runs:
How the HMR loader actually behaves
The hmr.js shim is clever, and understanding it explained every failure that followed. On each page load it races a dynamic import() of the Vite client against a one-second timeout. If the dev server answers, it wires up HMR and live CSS injection. If it times out or errors, it falls back to appending the prebuilt app.css. Crucially, it builds the dev-server URL from the current page’s hostname:
1 2 3 4 5 6 7 8 9 10 11 | var hostname = window.location.hostname; var origin = 'https://' + hostname + ':' + 9200; // ... try { await Promise.race([import(origin + '/@vite/client'), timeout(1000)]); await import(origin + '/dev.js'); console.log('[vite] HMR enabled:', origin); } catch (e) { console.log('[vite] dev server not reachable, falling back to app.css', e); // inject style/app.css } |
That fallback is a blessing and a curse: the site never breaks if you forget to start the dev server, but it also means every misconfiguration fails silently — you just quietly get stale CSS and a one-line message in the browser’s DevTools console (Chrome’s, in my case). Keep that console open; it’s the only thing that tells you which path you’re on. 💡
The certificate that wouldn’t import
Vite serves the dev server over HTTPS (it uses a basic self-signed-TLS plugin), so the very first hurdle was Chrome refusing the self-signed certificate with net::ERR_CERT_AUTHORITY_INVALID. The HMR import was being blocked before it ever connected. The plan was to trust the cert in the macOS keychain — but the command failed:
1 2 3 4 | sudo security add-trusted-cert -d -r trustRoot \ -k /Library/Keychains/System.keychain \ node_modules/.vite/basic-ssl/_cert.pem # SecCertificateCreateFromData: Unknown format in import. |
The reason: that _cert.pem bundles both the RSA private key and the certificate in one file, leading with the —–BEGIN RSA PRIVATE KEY—– block. The security tool expects a certificate, chokes on the leading key, and bails. Extracting just the certificate block fixed the import:
1 2 3 | openssl x509 -in node_modules/.vite/basic-ssl/_cert.pem -out /tmp/vite-cert.pem sudo security add-trusted-cert -d -r trustRoot \ -k /Library/Keychains/System.keychain /tmp/vite-cert.pem |
The hostname mismatch — the real bug
Cert trusted, Chrome restarted, page reloaded… still falling back to app.css. The browser console gave it away:
1 2 3 | [vite] dev server not reachable, falling back to app.css TypeError: Failed to fetch dynamically imported module: https://dev.example.test:9200/@vite/client |
There it is. I was browsing via the friendly proxy host, so hmr.js derived the dev-server URL as https://dev.example.test:9200 — but Vite was bound to localhost with a certificate valid only for localhost. Two problems stacked: the hostname didn’t resolve to anything Vite was serving, and the cert I’d just trusted was for the wrong name. Trusting a localhost cert can never satisfy a request to dev.example.test:9200.
Why I did NOT reach for Caddy
My instinct was to reuse the Caddy proxy: add a :9200 site block on the same friendly host, terminate TLS with the already-trusted cert, and reverse-proxy to Vite. I wrote the block, went to reload Caddy, and it refused to start:
1 2 | Error: loading initial config: ... listening on :9200: listen tcp :9200: bind: address already in use |
Of course — Vite already owns port 9200. Caddy and Vite can’t both bind it. I could have moved Vite to an internal port (say 9201) and proxied 9200 → 9201 through Caddy, but that’s three moving parts: a port shuffle, a proxy block, and an origin that now points at the wrong port and so leans on permissive CORS (Cross-Origin Resource Sharing — the browser rule governing which origins may load each other’s assets) to work at all. It’s a lot of machinery to paper over a problem the dev server can solve itself.
The clean fix: point Vite at the trusted cert directly
The Vite config already exposed the right knobs through environment variables — VITE_HOST drives both the bound hostname/port and the advertised origin, and SSL_CERT / SSL_CERT_KEY let you skip the self-signed plugin and use your own certificate. So the entire fix collapsed into a small .env file in the theme directory:
1 2 3 | VITE_HOST=https://dev.example.test:9200 SSL_CERT=/path/to/dev.example.test.pem SSL_CERT_KEY=/path/to/dev.example.test-key.pem |
Now Vite binds 9200 directly on all interfaces, serves the dev server with the same trusted certificate that fronts the main site, and — because VITE_HOST sets the origin too — advertises asset URLs on the matching hostname. No Caddy block, no port juggling, no tls_insecure_skip_verify. After a dev-server restart, the browser console finally read:
1 | [vite] HMR enabled: https://dev.example.test:9200 |
🔍 One quirk worth expecting: in HMR mode the stylesheet loads via JavaScript after the page paints, so navigating between pages flashes briefly unstyled before the CSS snaps in. That flash-of-unstyled-content only happens in development — production renders the app.css link in the document head, so the styles are present before first paint.
What I took away
Three lessons worth keeping. First, a silent fallback hides every misconfiguration behind a working-but-stale page — when something “doesn’t update,” check the browser’s DevTools console for the fallback log before touching caches. Second, when a client derives a URL from window.location.hostname, the dev server has to live on that exact host and present a cert for it; matching one without the other gets you nowhere. And third, before bolting a reverse proxy onto a problem, check whether the tool already has a config flag for it. The proxy was a 40-minute detour; the real fix was three environment variables. 🎉