Creating MCP Server for LM Studio and Open WebUI

Understanding the Three-Layer Architecture

When integrating AI applications with existing hardware or services, you don’t need to modify your device code. Instead, you introduce an MCP Server as middleware—a translator between the AI world and your real-world devices. 🤖

The Architecture

1. AI Frontend (LM Studio / Open WebUI) – Knows how to talk to MCP servers
2. MCP Server (Your custom Python/Node script) – The middleman that bridges AI and devices
3. Your Device (Existing service) – Unchanged, continues working as before

Real-World Example: Water Pump Controller

Imagine you have a water pump controller running at http://127.0.0.1:9090 that returns “Alive” when queried.

Your Goal: Type in LM Studio “Check if my water pump controller is running!” and have it automatically query your pump without modifying any device code.

The MCP Server Script

Here’s how you write the middleware in Python:

1
2
3
4
5
6
7
8
9
10
11
12
13
from mcp.server.fastmcp import FastMCP
import requests

mcp = FastMCP("WaterPumpTools")

@mcp.tool()
def check_pump_status() -> str:
    """Checks if the water pump controller is currently running and active."""
    try:
        response = requests.get("http://127.0.0.1:9090", timeout=5)
        return f"The pump responded with: {response.text}"
    except requests.exceptions.RequestException:
        return "The pump controller is offline or unreachable."

The Interaction Flow

1. You type in LM Studio: “Check if my water pump controller is running!”

2. LM Studio reads your prompt and discovers the check_pump_status tool from your MCP server

3. LM Studio delegates to MCP Server: “I need to check the pump status. Please execute this tool.”

4. MCP Server executes: Performs requests.get(“http://127.0.0.1:9090”) behind the scenes

5. Your pump responds: Returns “Alive” (unchanged behavior)

6. MCP Server responds to LM Studio: “The pump responded with: Alive”

7. LM Studio translates: Converts raw data to natural language: “Yes, I checked your controller and it is fine and alive!”

💡 Key Benefit: Your hardware controllers stay simple and pure—no complex AI protocols needed. The MCP server handles all the translation.

Configuring LM Studio

Step 1: Open the Configuration File

• Open LM Studio
• Click the Plug icon in the right sidebar (or go to Program tab)
• Click Install ➡️ Edit mcp.json

Step 2: Add Your MCP Server

Add your script to the mcpServers object:

1
2
3
4
5
6
7
8
{
  "mcpServers": {
    "water-pump-controller": {
      "command": "python3",
      "args": ["/absolute/path/to/your/pump_script.py"]
    }
  }
}

Replace /absolute/path/to/your/pump_script.py with the actual location of your Python file.

Step 3: Activate in Chat

• Save the configuration file
• Go back to the Chat view
• Click the Plug icon again
• You’ll see “WaterPumpTools” listed as an available server
• Toggle it ON

Your local LLM now knows about check_pump_status and will use it when appropriate!

Configuring Open WebUI

Open WebUI runs inside Docker, so it needs your MCP server to be a running background service rather than a local script.

Step 1: Run Your Script as a Web Service

Modify the bottom of your Python script to run as a persistent server:

1
2
if __name__ == "__main__":
    mcp.run(transport="http", port=8001)

Run it in your terminal:

1
python3 pump_script.py

It will stay active and listen on port 8001.

Step 2: Add to Open WebUI Admin Settings

• Open Open WebUI in your browser
• Log in as Administrator
• Navigate to ⚙️ Admin Settings ➡️ External Tools
• Click + to add a new server
• Set the configuration:

Type: MCP (Streamable HTTP) (not OpenAPI!)
Server URL: http://localhost:8001/mcp (or http://host.docker.internal:8001/mcp if using Docker Desktop)
Auth: None

• Click Save

Step 3: Enable for Your Model

• Go to the Models dashboard in Open WebUI
• Click your preferred model (e.g., Gemma 4, Qwen3)
• Scroll to the Tools section
• Find check_pump_status and toggle it ON

Now when you chat with that model, Open WebUI will automatically relay requests to your local MCP server!

Key Takeaways

No Device Modification: Your hardware stays unchanged—it’s still just returning data over HTTP

Clean Separation: MCP acts as a pure translation layer between AI and real-world systems

Scalable: Add multiple tools to your MCP server for different device queries

Local and Private: Everything runs on your machine with no cloud dependency

By using MCP as middleware, you gain the full power of AI-assisted device control while keeping your legacy systems and hardware controllers simple and independent. 🚀

Posted in Uncategorized | Tagged , | Leave a comment

Breaking Free from the Cloud: A Guide to Free Local AI Tools and Agents in 2026

The landscape of Artificial Intelligence shifted dramatically heading into 2026. While cloud-based subscriptions were once the default for accessing capable large language models (LLMs), the open-source community and consumer hardware have caught up. 💡 Today, individuals can run highly capable AI completely free, locally, and privately on their own machines.

Moving your AI stack local means zero subscription costs, absolute data privacy, and the ability to work entirely offline. However, running AI locally requires a baseline understanding of hardware constraints. To run modern, highly capable quantized models—such as Gemma 4, Qwen3, or specialized coding models—your computer needs a sizeable amount of unified memory. While smaller models can run on less, 16GB of RAM has established itself as the absolute practical minimum for an efficient, localized workflow.

Core Concepts Demystified

Before choosing a software application, it’s essential to understand how the components of a local AI setup fit together.

LLM Engine vs. Frontend Interface

Local AI is typically split into two layers: the engine (or “runner”) and the user interface (UI). The engine runs in the background, managing hardware resources and processing the raw mathematics of the neural network. The UI is the “wrapper” or chat window you interact with, translating your text inputs into instructions the engine understands.

RAG vs. Live Web Search

There’s often a misunderstanding regarding how local models access external information:

  • Retrieval-Augmented Generation (RAG): This is the practice of connecting your AI model to private, local documents (such as PDFs, text files, or markdown repositories). The software chops your documents into searchable chunks so the AI can reference them dynamically.
  • Live Web Search: This is web browsing integration. It allows the AI to query public search engines (like Google, Brave, or SearXNG) to fetch the latest real-time information from the internet.

What is an AI Agent?

Standard AI operates in a passive “chat loop”—you send a prompt, and it replies. An AI Agent, by contrast, is given autonomy. It can evaluate a complex prompt, break it down into sequential steps, and execute real-world actions. Through standardized protocols, local agents can autonomously call external APIs (Application Programming Interfaces), perform live web lookups, and safely read or write files directly onto your computer’s file system.

The Local Software Stack

Choosing how to run your local environment depends on your technical comfort level and exact project requirements. 🛠️

Terminal Tier: Ollama

For developers and power users, Ollama is the premier choice. Installed easily via macOS Homebrew, Ollama acts as a lightweight, background engine. It has no built-in graphical interface or default model; instead, users utilize the terminal to pull and chat with models using standard commands:

1
ollama run gemma4

Ollama is fast, efficient, and serves as the local backend API provider for almost every desktop GUI available.

Desktop Tier: LM Studio

LM Studio provides a highly polished, desktop application experience akin to an app store for AI. It features a built-in repository where you can search for and download thousands of open-source models with a single click. LM Studio provides robust, native drag-and-drop RAG (Retrieval-Augmented Generation) capabilities for local files and features native support for agentic tool integration.

Web/Enterprise Tier: Open WebUI

Open WebUI replicates the expansive web interfaces of premium enterprise tools like ChatGPT. Best deployed locally using Docker Desktop, it provides a collaborative, multi-user web interface. Out of the box, it offers advanced vector-based RAG indexing alongside native integration for live web searching via modern search provider APIs.

Connecting the Stack via MCP (Model Context Protocol)

If you want to program an AI runner like LM Studio or Open WebUI to execute actions on your machine (like mutating your file system or running a custom script), the industry standard is the Model Context Protocol (MCP).

MCP acts like a standardized “USB-C port” connecting AI models to external tools. You write a standard, non-AI server in Python or Node.js that exposes basic terminal or OS functions. The frontend UI detects these capabilities and passes them to the local model. 🔌

How MCP Workflow Works

Here’s how a user request flows through an MCP environment: when you type a complex prompt, the frontend sends it to the local model along with available MCP tool schemas. The model realizes what external capabilities it needs (web search, file access, etc.) and returns tool calls. The frontend translates these into actual operations—calling your MCP server, which executes the actions on your machine (searching the web, writing files), then feeds the results back to the model to generate a final response.

MCP Sequence Diagram

The MCP workflow: from user prompt to tool execution and final response.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
sequenceDiagram
    autonumber
    actor User as 👤 User Mac Browser/App
    participant UI as &#x1f4bb; Frontend UI<br/>(LM Studio / Open WebUI)
    participant LLM as &#x1f9e0; Local LLM Engine<br/>(Ollama / Local Model)
    participant MCP as &#x1f50c; Your Custom Server<br/>(Standard Python/Node App)
    participant OS as &#x1f4c1; Mac OS / Internet

    User->>UI: Type: "Find today's hot topics and save to summary.txt"
    UI->>LLM: Send prompt + Available MCP tool schemas
    Note over LLM: LLM realizes it needs external info<br/>& file access to fulfill request.
    LLM-->>UI: Return Tool Calls:<br/>1. search_web("hot topics 2026")<br/>2. modify_local_file("summary.txt", content)
   
    rect rgb(240, 248, 255)
        note right of UI: MCP Translation Layer Activating
        UI->>MCP: POST /tools/call (search_web)
        MCP->>OS: Execute Google Search / Fetch Live Data
        OS-->>MCP: Return search results
        MCP-->>UI: Send raw text data back
    end

    rect rgb(245, 245, 245)
        UI->>MCP: POST /tools/call (modify_local_file)
        MCP->>OS: Write summary.txt to file system
        OS-->>MCP: File write success
        MCP-->>UI: Send confirmation back
    end

    UI->>LLM: Provide raw tool execution results
    LLM-->>UI: Generate final conversational response
    UI->>User: Display: "I've searched the web and successfully created your summary file!"

To configure this, users simply add their local server’s path to LM Studio’s internal mcp.json config file or navigate to Admin Settings ➡️ External Tools within Open WebUI to bind the live server stream.

Technical Comparison

Feature/Capability Ollama (Terminal) LM Studio Open WebUI AnythingLLM
Primary Interface Command Line (CLI) Desktop GUI App Web-based Browser UI Desktop / Web GUI
Target User Developers / Power Users Individuals wanting a polished local app Teams, developers, and advanced users Users focused on organized data workspaces
Out-of-the-box RAG ❌ No (Requires external script/app) Yes (Drag-and-drop docs, built-in embeddings) Yes (Native vector DBs, hybrid search) Yes (Highly customizable document parsing)
Live Web Search ❌ No ⚠️ Via specific plugins/MCP tools Yes (Native integration with search APIs/SearXNG) Yes (Built-in agent search providers)
File System Mutation / Agents ⚠️ Limited (Via custom CLI wrappers) Yes (Supports MCP Agent infrastructure) Yes (Via Open Terminal, Python Tools, and MCP) Yes (Native file-system writing capabilities)
Ease of Setup Moderate (Requires Homebrew/Terminal) Very Easy (One-click installer) Moderate (Best run via Docker Desktop) Easy (Desktop app available)

The Bottom Line

Running free local AI in 2026 is no longer a compromised experience reserved strictly for software engineers. While a baseline hardware requirement of 16GB RAM is an unavoidable gatekeeper, the combination of lightweight engines like Ollama, rich interfaces like LM Studio or Open WebUI, and standard protocols like MCP allow individuals to orchestrate powerful, private, and fully agentic workflows directly from a personal computer. 🚀

Posted in Uncategorized | Tagged | Leave a comment

Reviewing Next.js as a Java Programmer

I came to Next.js from years of Java, and my first reaction was frustration. Java is verbose and has to be compiled, but in the end it’s predictable. Next.js is verbose and syntactically strange and hides an enormous amount of behavior behind the scenes — and it still has to be compiled. After fighting it for a while, I think the friction is real, but most of it comes from a handful of concepts that have no clean Java equivalent. Here’s my honest review. ☕

The part that actually breaks your brain: where does this code run?

In Java, your code runs on the Java Virtual Machine (JVM). Period. In Next.js (App Router), a single file might run on the server, in the browser, or during the build — and the rules differ for each. By default a component renders on the server. Add a click handler and you get an explicit build-time error telling you event handlers can’t live in a Server Component. To opt that file into the browser you add a magic string at the very top:

1
2
3
4
5
'use client';

export default function LikeButton() {
  return <button onClick={() => alert('hi')}>Like</button>;
}

One nuance worth correcting, because it tripped me up: a Client Component is not browser-only. It still renders once on the server during Server-Side Rendering (SSR), then again in the browser. “Client” means “also ships to the client,” not “runs only on the client.” Once that clicked, half my confusion went away.

Magic file names instead of configuration

Spring Boot’s @GetMapping(“/route”) is annotation magic, but it’s explicit — the route lives next to the method. Next.js routes by file name instead. Name a file page.tsx and it becomes a page; layout.tsx wraps it; route.ts becomes an HTTP endpoint. Typo the name and the route silently disappears, with no compiler complaint. Powerful once you internalize it, unnerving until you do.

JSX mixes what Java keeps apart

Java separates logic from presentation. JSX deliberately fuses them — an asynchronous database call sitting inside what looks like an HTML template, with JavaScript’s ternary and && operators standing in for if/else:

1
2
3
4
5
6
7
8
9
10
export default async function Dashboard() {
  const users = await db.getUsers(); // server-side query
  return (
    <div>
      {users.length === 0
        ? <p>No users found</p>
        : users.map(u => <UserCard key={u.id} name={u.name} />)}
    </div>
  );
}

It looks messy to a Java eye, but it’s deliberate: the component is the controller and the view at once. Fight that and you’ll be miserable; accept it and it’s surprisingly compact. 💡

The caching claim everyone repeats is out of date

You’ll read that Next.js “aggressively overrides fetch and caches everything by default.” That was true in Next.js 13 and 14, and people hated it. Next.js 15 reversed it: fetch requests, GET route handlers, and the client router cache are no longer cached by default — you opt in now. If you’re learning today, ignore the old horror stories. (Also: it overrides fetch on the server, not the browser’s native fetch.)

Compiling the uncompilable

One thing the internet routinely garbles: the Rust-based compiler that transforms your code is SWC; Turbopack is the Rust-based bundler. They’re two different tools, and neither one is what does server-rendering or hydration — that’s the React runtime. When a Java compile fails, the stack trace points at a line. When Next.js fails, you often get a cryptic “hydration mismatch” because the server-rendered HTML didn’t match what the browser produced. That error class is the single biggest “why is this happening” moment for newcomers.

A translation table for your Java brain

What finally made it tolerable was mapping each concept to something I already knew:

Next.js / React Rough Java / Spring equivalent
Server Component A controller that renders a view
Client Component (‘use client’) A browser script that also pre-renders on the server
page.tsx A @GetMapping(“/route”)
layout.tsx A master template / layout decorator
Props Constructor arguments

Verdict

Next.js is not unpredictable because it’s badly designed — it’s unpredictable because it asks one file to live in three environments, and Java never had to. The verbosity is real, the magic is real, and the error messages can be genuinely worse than a Java stack trace. But once you stop expecting JVM-style “what you see is what runs” and accept the server/client split as the core mental model, the rest stops feeling like chaos. Coming from Java, you already think in layers — you just have to learn which layer a given line of code is standing on. 🎉

Posted in java, javascript | Tagged , , , | Leave a comment

Profiling a Dockerized Moodle with Xdebug and QCachegrind on macOS

If you run Moodle under moodle-docker on a Mac and a page feels slow, eyeballing the code only gets you so far. The honest answer is a profiler. 🐳 Here’s how I wired up Xdebug for both step-debugging and on-demand profiling, dumped the results to my host, and opened them in QCachegrind — plus two gotchas that ate an hour before I spotted them.

Why bake Xdebug into the image

The official moodlehq/moodle-php-apache image ships without Xdebug on purpose, and the documented fix is a runtime pecl install. That works until your next down/up, when the container is recreated from the bare base image and your extension vanishes. So instead I extended the base image with a tiny Dockerfile and wired it into local.yml, which moodle-docker auto-includes. Now Xdebug survives rebuilds.

1
2
3
4
5
ARG MOODLE_DOCKER_PHP_VERSION=8.3
FROM moodlehq/moodle-php-apache:${MOODLE_DOCKER_PHP_VERSION}
# Idempotent: skip the install if a prior build already baked Xdebug in.
RUN php -m | grep -qi '^xdebug$' || (pecl install xdebug && docker-php-ext-enable xdebug)
COPY xdebug.ini /usr/local/etc/php/conf.d/zz-xdebug.ini

The config runs Xdebug in both debug and profile mode, but only when triggered — so normal browsing pays zero overhead. The profiler writes to a directory that I bind-mount to my Mac, so the output files are sitting right there in the host filesystem.

1
2
3
4
5
6
xdebug.mode=debug,profile
xdebug.start_with_request=trigger          ; only fires on ?XDEBUG_TRIGGER=1
xdebug.client_host=host.docker.internal    ; works on Docker Desktop for Mac
xdebug.client_port=9003
xdebug.output_dir=/var/www/xdebug          ; bind-mounted to the host
xdebug.use_compression=0

With that in place, profiling a page is just a query parameter: visit http://localhost:8000/?XDEBUG_TRIGGER=1 and a cachegrind.out.* file lands in the mounted directory. On macOS the tool you want is QCachegrind (the Qt port of KDE’s KCachegrind — KCachegrind itself is Linux/KDE). Install it with brew install qcachegrind graphviz, then open the file:

1
qcachegrind ./xdebug/out/cachegrind.out.*

Gotcha #1: BuildKit’s separate image store

My first build tagged the custom image with the same name as the base image. Two problems compounded. First, that overwrote the base tag in my local store, so the next build’s FROM pulled an already-Xdebugged image and pecl install failed with “already installed”. Second — the sneaky one — BuildKit on Docker Desktop writes to a containerd snapshotter store that the classic runtime doesn’t read from. So I’d rebuild, see “Successfully built”, recreate the container… and it was running stale bytes from a different store. The build and the runtime were looking at two different image stores. 😤

The fix was to give the custom image a distinct tag and build it with the classic builder so it lands where the runtime actually reads:

1
2
3
4
DOCKER_BUILDKIT=0 docker build \
  --build-arg MOODLE_DOCKER_PHP_VERSION=8.3 \
  -t moodle-webserver-xdebug:8.3 \
  ./xdebug

Gotcha #2: Xdebug 3.5 gzips the output by default

This one is quieter. Xdebug 3.5 sets xdebug.use_compression=on by default, so your profiler files come out as cachegrind.out.<host>.<time>.gz. QCachegrind can’t read a gzipped cachegrind file — it just won’t open it, with no especially helpful error. The one-line fix is in the config above: xdebug.use_compression=0. After that the files are plain ASCII text, and QCachegrind opens them happily. 💡

The payoff

Once it’s set up, the loop is tight: hit a slow page with the trigger parameter, alt-tab to QCachegrind, and the call map shows you exactly which functions are eating the wall-clock and how many times they’re called. For a sprawling codebase like Moodle — where one page render can fan out into hundreds of database calls and plugin hooks — that call graph is worth far more than another round of guessing. 🎉

Posted in Moodle | Tagged , , , , | Leave a comment

Setting Up a Moodle REST API Webservice in Five Steps

Moodle ships with a full web-services layer, but the admin flow for handing out a single API token is spread across half a dozen settings pages. The first time you do it, you’ll click around for twenty minutes wondering why your token keeps returning invalidtoken. 🔐 Here’s the short version I keep in my notes so I never have to rediscover it.

The goal: a token that lets an external client call exactly one function — core_webservice_get_site_info — over the REST (Representational State Transfer) protocol. Lock it down to one function and one user, and you have a safe little health-check endpoint you can curl from anywhere.

The five steps

1. Create the user. Site Administration → Users → Accounts → Add a new user. This is the identity the token acts as, so give it only the capabilities it needs — a dedicated service account beats reusing a human login.

2. Create the external service. Site Administration → Server → Web services → External services → Add. Name it (e.g. “Test API”), tick Enabled, and tick Authorised users only. That last checkbox is the one everyone forgets — without it, your carefully-scoped service is open to any token holder.

3. Add functions to the service. On the External services list, click Functions next to your new service, then Add functions, search for core_webservice_get_site_info, and save. Only the functions you add here are callable — this is your allowlist.

4. Authorise the user. Back on the External services list, click Authorised users and move your service account from the right column to the left. Because you ticked Authorised users only in step 2, skipping this means every call returns accessexception. 🙃

5. Generate the token. Site Administration → Server → Web services → Manage tokens → Add. Pick your user, map them strictly to your new external service, and copy the token it gives you.

Calling it

Three parameters and you’re done — the token, the function name, and the response format. No headers, no OAuth dance:

1
2
3
4
curl -X POST "https://moodle.example.com/webservice/rest/server.php" \
     -d "wstoken=YOUR_TOKEN_HERE" \
     -d "wsfunction=core_webservice_get_site_info" \
     -d "moodlewsrestformat=json"

A healthy response echoes the site name, your user details, the Moodle release, and the list of functions the token is allowed to call:

1
2
3
4
5
6
7
8
9
10
11
12
{
  "sitename": "My Learning Site",
  "username": "apiservice",
  "firstname": "API",
  "lastname": "Service",
  "userid": 42,
  "release": "4.5.3 (Build: 20250317)",
  "version": "2024100703",
  "functions": [
    { "name": "core_webservice_get_site_info", "version": "2024100703" }
  ]
}

That functions array is the quickest way to confirm your allowlist took effect — if it’s empty or missing the function you expected, you skipped step 3.

One last thing worth saying out loud: a Moodle web-service token is a credential. It acts as the user you mapped it to, for every function you allowed. Treat it like a password — keep it out of version control, scope it to a throwaway account on dev environments, and rotate it the moment it leaks. A token committed to a repo is a token you no longer control. 🍻

Posted in Moodle | Tagged , | Leave a comment

Enabling Vite Hot Reload for Moodle SCSS

🐳 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:

1
2
3
4
if (!defined('ENV_LOCAL')) {
    define('ENV_LOCAL', 'local');
}
$CFG->env = ENV_LOCAL;

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. 🎉

Posted in DevOps | Tagged , , | Leave a comment

A Quick Tour of Mustache Templating in PHP (and Everywhere Else)

If you’ve worked in a large PHP codebase like Moodle, you’ve bumped into files ending in .mustache and wondered what those double curly braces are doing. 🐘 The short version: Mustache is a logic-less templating language, and the syntax you learn once works in about 40 other languages too. It’s a specification, not just a library.

The whole language fits in a handful of tag types. Here they are, with the behavior that trips people up:

1. Escaped values{{ name }} outputs a value with HTML escaping applied, so an angle bracket comes out as a harmless entity. This is the safe default for any text.

2. Raw values{{{ icon }}} with triple braces emits the value as-is, no escaping. Use it only for trusted HTML you deliberately built (an SVG string, say). Everything else stays double-braced.

3. Sections{{#thing}} … {{/thing}} renders the inner block when thing is truthy. If thing is a list, the very same syntax loops instead. Context decides.

4. Inverted sections{{^thing}} … {{/thing}} is the only “else” Mustache gives you: it renders when the value is falsy. There is no {{else}} keyword. 💡

Put a section and an inverted section on the same variable and you get the classic if/else. Here’s a real example: an action button that’s a working link when a feature is on, and an inert, greyed-out button with a tooltip when it’s off.

1
2
3
4
5
6
{{#disabled}}
<a class="btn disabled" href="#" aria-disabled="true" tabindex="-1" title="{{ disabledhint }}">{{ label }}</a>
{{/disabled}}
{{^disabled}}
<a class="btn" href="{{ url }}">{{ label }}</a>
{{/disabled}}

Notice what’s missing: there are no operators. You can’t write a logical-and or an equality check inside the braces. That’s the entire philosophy — the template decides which pre-computed pieces to show, but the deciding is done in your code. So the disabled boolean and the disabledhint string are both computed in PHP and handed over ready-made.

Speaking of PHP: rendering is refreshingly boring.

1
2
3
4
5
6
7
8
9
10
11
12
<?php
$engine = new Mustache_Engine([
    'loader' => new Mustache_Loader_FilesystemLoader(__DIR__ . '/templates'),
]);

$context = [
    'disabled'     => true,
    'label'        => 'Edit Profile',
    'disabledhint' => 'Feature flag must be enabled',
];

echo $engine->render('account_card', $context);

The context is just an array (or object). PHP truthiness maps straight onto sections: false, null, an empty string, 0, and the empty array are all falsy and skip the section (and trigger the inverted one); a non-empty array becomes a loop. Closures passed as values act as “lambdas” — the one sanctioned place a sliver of logic sneaks back in.

Frameworks usually wrap this. In Moodle, you never touch Mustache_Engine directly — you call $OUTPUT->render_from_template(‘local_cert/pv/account_card’, $context), and the framework resolves the component path, injects helpers like {{#str}} key, component {{/str}} for translations, and supports template inheritance. The neat part: the same template file can also be rendered client-side by Moodle’s JavaScript layer, because there’s a JavaScript implementation of Mustache that produces identical output.

That cross-language portability is the real reason to like it. Learn the five tag types once, and you can read a template in Ruby, Go, JavaScript, or PHP without relearning a thing. The host Application Programming Interface (API) differs; the braces never do. 🎉

Posted in PHP, Web Development | Tagged , , | Comments Off on A Quick Tour of Mustache Templating in PHP (and Everywhere Else)

Adding a Moodle cron sidecar to moodle-docker via local.yml

The moodle-docker stack is designed primarily for automated testing — Behat, PHPUnit, and so on — which is why it doesn’t ship a cron container. A background scheduler would interfere with deterministic test runs, so the README’s only nod to it is “run cron.php manually if you want.” 🕒

That’s fine for testing. It’s less fine if you’re using the stack for day-to-day development, because the Moodle admin notifications page will pester you with the warning “The admin/cli/cron.php script has never been run and should run every 1 min” until cron actually runs on a sensible interval. This post is the working pattern I landed on: a tiny cron sidecar added through moodle-docker’s official extension point.

Use local.yml, not a fork

moodle-docker has a documented extension hook: any local.yml file in the md-docker directory is merged last into the compose invocation. It’s gitignored by default — purpose-built for per-machine overrides like this one — so additions here don’t conflict with future git pull on the upstream repo.

Here’s the full file. If you already have a local.yml (for a bind-mounted database, for the Moodle 5 APACHE_DOCUMENT_ROOT override, etc.), just add the cron service block to what you’ve got:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# md-docker/local.yml
services
:
  db
:
    volumes
:
     - "${MOODLE_DOCKER_DB_DATA}:/var/lib/mysql"
  webserver
:
    environment
:
      APACHE_DOCUMENT_ROOT
: /var/www/html/public
  cron
:
    image
: "moodlehq/moodle-php-apache:${MOODLE_DOCKER_PHP_VERSION}"
    depends_on
:
     - db
    volumes
:
     - "${MOODLE_DOCKER_WWWROOT}:/var/www/html"
    environment
:
      MOODLE_DOCKER_DBNAME
: moodle
      MOODLE_DOCKER_DBUSER
: moodle
      MOODLE_DOCKER_DBPASS
: "m@0dl3ing"
    # Run cron every minute (Moodle's recommended cadence). Inner cron.php
    # polls for 30 seconds (cron_keepalive in the DB), then bash sleeps 60s
    # before relaunching. Total cycle ~91s — within Moodle's 120s threshold.
    command
: ["bash", "-c", "while true; do php /var/www/html/admin/cli/cron.php; sleep 60; done"]
    restart
: unless-stopped

A few things worth noting about that block:

  • Same image as the webserver. moodlehq/moodle-php-apache:${MOODLE_DOCKER_PHP_VERSION} ensures the cron process runs on the same PHP version your web requests do — no surprises around extension availability or version-specific bugs.
  • Same code mount. Bind-mounting ${MOODLE_DOCKER_WWWROOT} means cron sees exactly the code the webserver sees, including your in-progress edits.
  • DB env values match moodle-docker’s defaults. The hardcoded moodle / moodle / m@0dl3ing triplet matches the base.yml defaults so the cron container talks to the same database the webserver does. If you’ve customised these in your own setup, mirror those changes here.
  • while true; do php cron.php; sleep 60; done is the heart of it. Moodle recommends cron every minute, and the sleep 60 gives us exactly that. The reason for an explicit sleep rather than a tighter loop is twofold. First, Moodle’s interactive backup/restore wizards create database controllers in a transient “awaiting” state and then queue an async task for cron to finish — if cron polls aggressively, it picks up the controller before the user has clicked through the wizard’s confirmation steps, sees the wrong state, and marks the job failed (the cron log shows “Bad backup controller status, is: 800 should be 700, marking job as failed”). Production sites running cron every minute give the wizard plenty of buffer; a stack that polls every second consistently races it. Second, log volume — without a sleep, you get per-second “Cron run completed correctly” lines that make tailing the container useless for actual debugging.
  • restart: unless-stopped for safety — if cron.php exits with an error and the outer shell loop dies for some reason, Docker brings the container back up.

Quiet the inner task poller

Even with sleep 60 between invocations, each cron.php run has its own internal poll that loops for $CFG->cron_keepalive seconds before exiting. On a fresh moodle-docker install this defaults to 180, so every invocation spends three minutes printing per-second status lines like this:

1
2
3
4
Ran 0 adhoc tasks found at Thu, 28 May 2026 16:53:03 +0100
Cron run completed correctly
Cron completed at 16:53:03 in 0.010706 seconds. Memory used: 52.0 MB. Continuing to check for tasks for 106 more seconds.
Ran 0 adhoc tasks found at Thu, 28 May 2026 16:53:04 +0100

It’s a database config value, not a CLI flag — set it once from inside the webserver container and it persists across restarts:

1
2
3
4
5
6
bin/moodle-docker-compose exec webserver php -r '
  define("CLI_SCRIPT", true);
  require "/var/www/html/config.php";
  set_config("cron_keepalive", 30);
  echo "cron_keepalive now: " . get_config("core", "cron_keepalive") . PHP_EOL;
'

The value is a tradeoff against task latency. With cron_keepalive=30 + sleep 60, a freshly queued adhoc task is picked up within ~30 seconds of being created (during the inner poll window of any running invocation) and at most 60 seconds even if it lands during a sleep gap. With cron_keepalive=0, each cron.php exits immediately after one pass — no internal polling, completely quiet between sleeps, but tasks always wait the full sleep 60 at worst. I went with 30 as the default.

Pin the PHP version explicitly

The wrapper script bin/moodle-docker-compose provides a default of 8.3 for MOODLE_DOCKER_PHP_VERSION via a shell parameter expansion, but it’s worth setting the variable explicitly in your environment file so the value is documented in one obvious place and doesn’t drift if the wrapper’s default ever changes.

In md-docker/moodle-env50.sh (or whichever per-instance env file you source):

1
export MOODLE_DOCKER_PHP_VERSION=8.3

If you’re running multiple stacks (4.5, 5.0, etc.), add the same line to each env file, possibly with different version pins per stack.

Bring it up

1
2
3
cd md-docker
source moodle-env50.sh
bin/moodle-docker-compose up -d cron

You should see Container [project]-cron-1 Started in the output. From this point on, docker compose up / down brings cron up and tears it down alongside the rest of the stack — no stray processes left running when you stop your dev environment.

Validation

Moodle records the cron’s last start time in the tool_task plugin’s config table. You can read it directly from inside the cron container:

1
2
3
4
5
6
7
8
bin/moodle-docker-compose exec cron php -r '
  define("CLI_SCRIPT", true);
  require "/var/www/html/config.php";
  $start = get_config("tool_task", "lastcronstart");
  $interval = get_config("tool_task", "lastcroninterval");
  echo "lastcronstart: " . date("Y-m-d H:i:s", $start) . " (" . (time() - $start) . " sec ago)" . PHP_EOL;
  echo "lastcroninterval: $interval seconds" . PHP_EOL;
'

After cron has been running for a couple of minutes, you want to see something like:

1
2
lastcronstart: 2026-05-28 14:34:43 (45 sec ago)
lastcroninterval: 91 seconds

lastcronstart updated within the last minute or so, lastcroninterval in the 80-100s range (matching the cycle of cron_keepalive 30s + sleep 60s). That’s the healthy state.

How the admin warning unwinds

If you check the Moodle admin notifications page (Site administration → Notifications) while this is happening, the warning text changes through three states as cron settles in:

  1. “…has never been run and should run every 1 min.” The starting state, before the cron container has touched the database.
  2. “There was 4 mins between the last two runs of the cron maintenance script and it should run every 1 min.” The middle state, after a single cron cycle but before two consecutive cycles have happened inside the expected frequency. This usually clears in 1-2 minutes once the container has been running steadily.
  3. (no cron mention at all) The healthy state. The admin notifications page shows only unrelated warnings.

If you’re stuck at state 2 for more than a few minutes, purge Moodle caches once (bin/moodle-docker-compose exec webserver php /var/www/html/admin/cli/purge_caches.php) and reload the admin page. The status checks are themselves cached, so the page can lag the actual state by a cycle or two. 🐳

Stopping and starting

Day-to-day this is invisible. bin/moodle-docker-compose up -d brings everything up, including cron. bin/moodle-docker-compose down stops it. bin/moodle-docker-compose logs -f cron tails the output if you want to see scheduled tasks executing in real time. The sidecar plays by the same rules as every other service in the stack — no host-side launchd, no host crontab entry, no stray processes after a docker compose down.

Posted in Moodle | Tagged , , | Comments Off on Adding a Moodle cron sidecar to moodle-docker via local.yml

Creating a new theme and changing font on Moodle 5

I wanted to swap the default font in a Moodle 5 install. Specifically: keep Boost, the stock Moodle theme, but use Inter from Google Fonts instead of the Bootstrap system stack. The official path is “make a child theme of Boost.” This sounds like ten minutes of work. 🐳

It took an afternoon. Not because Moodle theming is hard exactly — it’s actually quite powerful — but because the developer experience has a handful of rough edges that all bite on the same kind of small change. This post is the postmortem.

The setup, briefly

Moodle’s theme system works the way a lot of Cascading-Style-Sheet (CSS) framework users would expect: there’s a base theme (Boost) and you make a child theme that inherits from it. Override SCSS variables, override layout templates, override language strings — anything you don’t override falls through to the parent. The mechanism is solid.

What I wanted out of my child theme, called newboost, was modest: import Inter, set it as $font-family-sans-serif, done. The whole brief is one variable override and one external font file.

First mistake: copying Boost wholesale

My first instinct was the worst possible one: cp -r theme/boost theme/newboost, change the name in config.php and version.php, reload the page. Moodle collapsed with a fatal error.

The reason is the kind of thing you only see if you actually look at version.php:

1
2
3
4
5
6
<?php
defined('MOODLE_INTERNAL') || die();

$plugin->version   = 2026042000;
$plugin->requires  = 2026041000;
$plugin->component = 'theme_boost';    // <- still says boost

I’d copied the file into theme/newboost/ but the $plugin->component string was still ‘theme_boost’. As far as Moodle’s plugin loader is concerned, that’s two Boosts trying to register with the same component name — fatal, refuses to load. And even if I’d fixed the component name, the copy had dragged along every classes/ file, all of which were namespaced under theme_boost\… — so the class loader sees two implementations of every Boost class and falls over.

The right thing is the opposite of “copy everything”: a Boost child theme should be the minimum set of files that override what you actually want different. For my single-variable change, that turned out to be six files. Total line count, around 50.

The six files

If you ever need this list as a reference, here it is. Everything goes under theme/newboost/:

  • version.php$plugin->component = ‘theme_newboost’, depends on theme_boost.
  • config.php$THEME->parents = [‘boost’] plus an scss closure.
  • lib.php — one function that returns your overriding SCSS as a string, then appends Boost’s default.scss for the rest of the cascade.
  • lang/en/theme_newboost.phppluginname, choosereadme, and configtitle. That’s it.
  • settings.php — at minimum the defined(‘MOODLE_INTERNAL’) || die(); guard. Optionally one admin_settingpage registration so the theme settings page resolves.
  • scss/preset/default.scss — the actual variable override.

Everything else (templates, layouts, icons, mustache, JavaScript) inherits from Boost. The whole point of a child theme is that you only write what’s different.

The Inter override that looked right and broke everything

Here’s what I put in scss/preset/default.scss on the first try:

1
2
3
4
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');

$font-family-sans-serif: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
$font-family-base: $font-family-sans-serif;

If you paste this into a real CSS file in a browser, it works fine — @import url() is part of CSS. But the compiler in play here is scssphp, the Pure-PHP-Sass-Compiler (no native sass binary, no node tooling — just PHP). And scssphp sees @import url(…), decides it should resolve url as a local file path, fails, and throws:

1
CompilerException: `url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap")` file not found for @import: line: 5, column: 1

So far so understandable. The infuriating part is the next layer. Moodle’s CSS-content function wraps the compile in try { … } catch (Throwable $e) { debugging(…) } — meaning the exception is swallowed, demoted to a debug-log message, and execution falls through to the parent theme’s pre-compiled CSS. There’s a literal file at theme/boost/style/moodle.css, about 1 MB of pre-baked Boost output, and Moodle helpfully serves that when SCSS compilation returns nothing.

From the browser’s perspective: the page renders, all the styles are there, the colours are right, Boost looks like Boost — and my override is nowhere. No error in the console. No notice on any admin page. No entry in the Hypertext-Transfer-Protocol (HTTP) error log. Pure silent failure with a fully styled fallback covering it up.

How I found it

I started by suspecting the things you always suspect first: stale caches (purged), browser cache (hard-refreshed), theme designer mode (enabled, then later disabled — more on that). I used the chrome-devtools Model-Context-Protocol (MCP) server to inspect document.styleSheets on the live page, which showed the main styles.php?theme=newboost&type=scss entry was loading but had zero matching variables for Inter. I dumped the compiled Cascading-Style-Sheet (CSS) and confirmed my override wasn’t in it. None of that pointed at the actual cause.

What worked, eventually, was a 10-line Hypertext-Preprocessor (PHP) script that called Moodle’s internal $theme->get_css_content_from_scss() directly, with the try/catch bypassed via reflection:

1
2
3
4
5
6
7
8
9
$compiler = new core_scss([]);
$compiler->append_raw_scss($scss($theme));
$compiler->setImportPaths($paths);
try {
    $css = $compiler->to_css();
} catch (Throwable $e) {
    echo "CAUGHT " . get_class($e) . ": " . $e->getMessage() . PHP_EOL;
    echo $e->getTraceAsString();
}

The stack trace appeared in about two seconds. Lesson learned: when SCSS “compiles to nothing” in Moodle, the failure is almost always in the compile itself, not in the caching or the routing. Reach for a script like that early. 💡

The fix

Two options. The cleanest is: don’t @import Google Fonts from SCSS at all. Instead, inject a real <link rel=”stylesheet”> tag via Moodle’s additionalhtmlhead config setting, which Moodle drops into every page’s <head>:

1
2
3
4
5
set_config('additionalhtmlhead',
    '<link rel="preconnect" href="https://fonts.googleapis.com">' .
    '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' .
    '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap">'
);

With that in place, the SCSS file shrinks to just the variable override and compiles without issue:

1
2
$font-family-sans-serif: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
$font-family-base: $font-family-sans-serif;

The other option is to self-host the woff2 files inside the theme and reference them via a local @font-face rule. Better for privacy and offline use, more work to maintain through Google Font version bumps. For a dev box I went with the <link> tag.

The second class of trap: body *

Once Inter was loading, I noticed some Bootstrap components — buttons, form inputs — were still falling back to system-ui because Bootstrap declares font-family directly on those elements rather than inheriting from body. The natural fix, and the wrong one, is:

1
body * { font-family: 'Inter', sans-serif; }

This works for text. It also breaks every icon on the site. Font Awesome icons aren’t real characters — they’re glyphs in a private icon font (“Font Awesome 6 Free”), referenced from <i class=”fa fa-star”> elements via the font-family declaration on .fa. Forcing Inter on those <i> tags makes the browser render whatever Unicode codepoint happens to occupy the icon’s glyph slot — usually a blank box, sometimes a weird character.

The cleaner fix is to be explicit about which elements need re-setting:

1
2
3
4
5
body, .btn, .form-control, .form-select, .dropdown-menu, .nav-link,
.breadcrumb, .modal, .popover, .alert, .badge, .card, .list-group-item,
h1, h2, h3, h4, h5, h6, input, textarea, select, button, label {
    font-family: $font-family-sans-serif;
}

Pedestrian, but it cooperates with how Bootstrap and Font Awesome both use font-family. Lesson learned: body * is almost always wrong. The cascade was designed for inheritance — work with it, don’t bulldoze it.

The two papercuts at the edges

Two smaller things ate time I didn’t expect to spend.

Theme designer mode. While debugging the SCSS-not-compiling issue, I turned on Moodle’s themedesignermode setting so compiled CSS wouldn’t be cached. Forgot to turn it off. Designer mode serves CSS through styles_debug.php instead of styles.php, splitting one bundle into about a hundred per-component requests — and one of those (the main SCSS slice) was returning zero bytes even though the SCSS compiled fine in isolation. The page rendered with no styles at all, like a 1996 hand-coded Hypertext-Markup-Language (HTML) document. Turning designer mode off and re-clearing caches fixed it instantly. If you’re going to enable it for debugging, set a reminder to turn it off afterwards.

The settings page that didn’t exist. Going to /admin/settings.php?section=themesettingnewboost returned “Section error!” until I did two things: register a minimal admin_settingpage in settings.php, and add a $string[‘configtitle’] entry in the language file. Until the language string cache flushes, the page heading literally renders as [[configtitle]]. Reasonable on reflection; surprising in the moment.

What I’d tell past-me

Three things:

  1. Don’t start from cp -r theme/boost. Read one of the existing minimal Boost child themes on GitHub, count the files (six), and copy that shape. The Boost source tree is enormous and dragging the whole thing in is a guaranteed failure mode.
  2. When SCSS “compiles to nothing,” Moodle silently falls back to the parent’s pre-compiled CSS. The failure is almost always in the compile itself, not the caching or routing. Bypass the try/catch and look at the actual exception — it’s right there.
  3. The CSS cascade was designed for inheritance. If Bootstrap is overriding font-family on .btn, re-override .btn specifically. Reaching for body * is the equivalent of using a sledgehammer where a Phillips-head will do, and it breaks anything that legitimately uses a non-default font-family — icon fonts being the obvious one.

Closing thought

Moodle’s theme system is more powerful than most Learning-Management-System (LMS) theming I’ve worked with. You really do get full SCSS inheritance, full template overrides, full plugin compatibility from the parent, all without forking. That’s rare and good.

But the developer experience around it has a stack of small frictions that all bite the same kind of small change. Silent SCSS errors. Parent precompiled-CSS fallback covering up your compile failure with something that looks fine. Designer-mode behavioural drift. Language-string caching. Settings-page registration boilerplate that you only know is missing because of an error message that doesn’t quite point at it. None of these are bugs exactly — they’re rough edges. Coming in expecting “the ten-minute job” sets you up to be frustrated. Budget a half-day; you’ll be fine. 🛠️

Posted in Moodle | Tagged , , , , | Comments Off on Creating a new theme and changing font on Moodle 5

Local HTTPS for dev sites with Caddy, mkcert, and a reverse-proxy gotcha

Running a local web app over plain HTTP is fine until something forces your hand — a third-party SDK that refuses to load on http://, a cookie that needs the Secure flag, or an app like Moodle that nags you on every page about being insecure. At that point you want a real-looking HTTPS URL pointing at localhost, with a certificate your browser doesn’t yell about. 🔐

The combo I keep coming back to: mkcert for the cert, Caddy as the reverse proxy, and a small /etc/hosts entry to make the hostname resolve. Total setup is maybe five minutes. Here’s the shape of it.

Step 1 — make a trusted cert with mkcert

mkcert installs a local Certificate Authority (CA) into your operating system’s trust store the first time you run it. After that, every cert it issues is automatically trusted by browsers on that machine. No self-signed warning popups.

1
2
3
4
5
6
7
8
9
# One-time setup
brew install mkcert nss     # nss = trust for Firefox
mkcert -install

# Issue a cert for your local hostname
cd ~/dev/myapp
mkcert myapp.example.test
# → myapp.example.test.pem        (cert)
# → myapp.example.test-key.pem    (private key)

Pick a hostname that won’t collide with a real one. The .test top-level domain is reserved for exactly this purpose by RFC 2606, so it’s a safer choice than inventing your own. I sometimes use my company’s domain for muscle memory — that works too, but be aware you’re squatting on a real Domain Name System (DNS) name locally.

Step 2 — make the hostname resolve to your machine

Your browser asks DNS where myapp.example.test lives. DNS has no idea — so you tell your machine directly, by editing /etc/hosts:

1
sudo nano /etc/hosts

Add a line:

1
127.0.0.1    myapp.example.test

That’s macOS and Linux. On Windows, the same file lives at C:\Windows\System32\drivers\etc\hosts and you’ll need to open your editor as Administrator to save it. (I haven’t run a Windows dev box in a while — if it’s different on a current version, the principle is the same: there’s a hosts file, it maps names to IPs, your changes win over real DNS.)

Verify it took effect:

1
ping myapp.example.test    # should resolve to 127.0.0.1

Step 3 — wire up Caddy as a reverse proxy

Caddy’s config is one of the friendlier ones to write. Each site is a block; tls takes the cert pair; reverse_proxy hands the request off to your app. Your Caddyfile ends up looking like this:

1
2
3
4
5
6
7
8
9
10
{
    auto_https disable_redirects
}

myapp.example.test {
    tls /Users/me/dev/myapp/myapp.example.test.pem /Users/me/dev/myapp/myapp.example.test-key.pem
    reverse_proxy localhost:8080 {
        header_up X-Forwarded-Proto https
    }
}

The auto_https disable_redirects in the global block is important — without it, Caddy will try to do its own HTTP-to-HTTPS redirect, which is helpful in production but interferes with local dev when your app is also issuing redirects.

Reload Caddy:

1
2
3
brew services restart caddy
# or, if running in foreground:
caddy reload --config /opt/homebrew/etc/Caddyfile

Hit https://myapp.example.test. You should land on your app, served on real-looking HTTPS, with a green padlock. 🎉

The gotcha: ERR_TOO_MANY_REDIRECTS

This one bites everyone the first time. You update your app’s configured base URL from http://localhost:8080 to https://myapp.example.test, reload the page, and the browser shows:

1
2
3
This page isn't working
myapp.example.test redirected you too many times.
ERR_TOO_MANY_REDIRECTS

Here’s what’s happening. Caddy terminates Transport Layer Security (TLS) on its side, then forwards a plain http:// request to your app on port 8080. Your app looks at the incoming request, sees it arrived as HTTP, but its configured base URL is HTTPS — so it issues a 302 back to the HTTPS URL. The browser follows it. Caddy receives it, strips TLS again, forwards HTTP. Loop forever. 🔁

Two fixes, both needed:

  1. Caddy tells the app it was HTTPS originally. The header_up X-Forwarded-Proto https line in the reverse_proxy block (shown above) does this.
  2. The app trusts that header. Frameworks usually have a flag for this. In Moodle it’s $CFG->sslproxy = true; in config.php. In Laravel it’s the TrustProxies middleware. In Express it’s app.set(‘trust proxy’, 1). Without this, the app keeps thinking it’s an HTTP request and the redirect loop continues.

Both halves matter. Forwarding the header without trusting it is silent; trusting a header that isn’t being sent does nothing. 💡

The whole flow, in one mental model

  1. mkcert gives you a trusted cert for a chosen hostname.
  2. /etc/hosts points that hostname at your machine.
  3. Caddy listens on 443 with the cert, terminates TLS, forwards the request to your app’s plain-HTTP port.
  4. X-Forwarded-Proto + the app’s “trust proxy” flag tells the app the original request was HTTPS, so it stops redirecting in circles.

Once the four pieces are in place, every new project that needs HTTPS is a two-minute job: one mkcert call, one new block in the Caddyfile, one hosts entry. Worth setting up once. 🛠️

Posted in DevOps | Tagged , , , | Comments Off on Local HTTPS for dev sites with Caddy, mkcert, and a reverse-proxy gotcha