Two Mac minis at home: one serves, the other works and thinks
ContenidoContents
These past few days I have been moving things between two Mac minis.
Until now the Mac mini M4 did everything: it served the website and the blog, the Apple fan site, the scheduled scripts and the Telegram alerts. Also everything for LiftCoach, my gym app: the API it syncs with (workouts, routines, nutrition and health, in SQLite) and its MCP server with OAuth, both run by launchd behind Apache. It was also where I wrote code. With the M6 at home, it was time to split the work.
The split
The M4 becomes a headless server. Website, blog, onemore, the launchd scripts and Telegram. Same as always, but without me sitting on it with an editor open. How that holds up from home I explained in Self-hosting without fear.
The M6 is the work machine. I code there, and Ollama (0.40.13) lives there, listening on http://Mac-mini-M6.local:11434.
The M4 scripts carry no model of their own. They call the M6’s Ollama over the home network. The server serves; the other one thinks.
| Mac mini M4 | Mac mini M6 | |
|---|---|---|
| Role | headless server | work and Ollama |
| Chip | Apple M4 | Apple M6 |
| CPU | 10 cores: 4 performance and 6 efficiency | 12 cores: 2 Super, 4 performance and 6 efficiency |
| GPU | 10 cores | 12 cores |
| Unified memory | 16 GB | 32 GB |
| Storage | 256 GB | 1 TB |
| System | macOS 27.2 | macOS 27.2 |
The M6’s 32 GB are what let me keep gemma4 loaded, at 7.7 GB, without taking memory away from whatever I am working on.
New models
On 30 October I switched the models the scripts use. I had already tried local AI on the Mac mini. Before writing this I measured it with today’s real summary data, three runs per model, and the small one did not fall as short as I thought: neither invented numbers and both took about the same, between 7 and 15 seconds.
The difference is consistency. gemma4 gave the same format and almost the same length all three times, 191 to 193 tokens. mistral swung between 180 and 358, and in one run it made up a “Disco:” section that was not in the format and left out the critical attacks. gemma4 is not perfect either: in one run it mixed the 6 Cloudflare threats with the 15 attacking IPs. For a report I read every morning, I prefer the one that comes out the same every day.
Three scripts generate text: the daily summary, the context attached to alerts and the failure detector. They move from mistral:7b to gemma4:12b, which takes 7.7 GB.
| mistral:7b | gemma4:12b | |
|---|---|---|
| Parameters | 7.2 billion | 12.4 billion |
| Size on disk | 4.4 GB | 7.7 GB |
| Context | 32,768 tokens | 262,144 tokens |
| Quantization | Q4_K_M | NVFP4 |
| Capabilities | text and tools | text, vision, audio, tools and reasoning |
| License | Apache 2.0 | Apache 2.0 |
With gemma4 there is one catch: you have to send "think": false. Otherwise it thinks out loud and the reasoning ends up in the message.
1{
2 "model": "gemma4:12b",
3 "prompt": "...",
4 "stream": false,
5 "think": false
6}
Related posts on the blog and on onemore use embeddings. They move from nomic-embed-text to qwen3-embedding:0.6b, using /api/embed and an instruction prefix before the text. Here the reason is simpler: nomic-embed-text has a 2048-token context and, above roughly 4000 characters, it returned an error, so each post was cut to 3500 characters or less before comparing. qwen3-embedding takes up to 12000. Thresholds change with the model: on the blog, 0.60 for related posts and 0.68 for the map; on onemore, 0.64.
| nomic-embed-text | qwen3-embedding:0.6b | |
|---|---|---|
| Parameters | 137 million | 596 million |
| Size on disk | 274 MB | 639 MB |
| Context | 2,048 tokens | 32,768 tokens |
| Vector dimensions | 768 | 1,024 |
| Quantization | F16 | Q8_0 |
I copied each script before touching it. That is the minimum when you change five things at once.
A token that should not have been there
The Telegram bot token was written inside the alert script’s code. I moved it out to a .env file with 600 permissions.
Doing that, I noticed something else: the summary and the detector had no token at all. They could not send anything. They had been working for nobody for a while.
The real problem: Local Network
On 31 October, at 8:00, the summary arrived on Telegram raw. Unwritten. The model had not answered.
In the log, errno 65: No route to host. But from Terminal, on the same M4, the M6 answered fine.
The difference was who launched the process. macOS has a privacy permission, Local Network, that decides which apps can talk to devices on your network. A python launched directly by launchd could not reach the M6. Neither the system python nor Homebrew’s python3.14. From Terminal it could, because there Terminal’s permission applies.
Switching to Homebrew’s python did not fix it. And a curiosity: that python does not even show up in Settings → Privacy & Security → Local Network. There is no switch to turn on.
What worked: launchd does not launch python, it launches /opt/homebrew/bin/bash with a wrapper script. That bash does have the Local Network permission, and python runs as its child process.
The wrapper:
1#!/opt/homebrew/bin/bash
2# wrapper.sh: python runs as a child of this bash, no exec
3/opt/homebrew/bin/python3 /Users/user/bin/my-script.py "$@"
4exit $?
The key detail is not using exec. With exec, python replaces bash and you are back where you started.
The plist points at bash, not python:
1<key>ProgramArguments</key>
2<array>
3 <string>/opt/homebrew/bin/bash</string>
4 <string>/Users/user/bin/wrapper.sh</string>
5</array>
6<key>StartCalendarInterval</key>
7<dict>
8 <key>Hour</key><integer>8</integer>
9 <key>Minute</key><integer>0</integer>
10</dict>
Before touching the real jobs, I tested it with a temporary launchd job that only called the model and sent nothing. It worked. Then I moved the three scripts to the wrapper.
It is not the first time macOS has changed the server’s rules under my feet. The other time was when a macOS update took down my server.
Where it lands
One Mac mini that serves and is left alone. Another that works and acts as the brain for the first.
What took the longest was not the model, it was a privacy permission you cannot see in Settings. If a launchd script says No route to host and everything works from Terminal, look at who launches the process.