The AI models behind chat assistants usually run in a company's data center. Some of them, including Qwen from Alibaba, are published openly, so you can download one and run it on hardware you control. Your prompts are processed there instead of being sent to a hosted AI provider.
The mini's main job is a quiz tutor I built to study for a certification exam. When I ask it a question, the request follows this path, and the answer travels back the same way:

New to local AI models or home servers? Start here (1 min)
What is actually running here?
- Qwen is the AI model: the part that generates answers.
- Ollama is the software that downloads and runs Qwen.
- The Mac mini is the always-on server. "Headless" simply means it has no monitor or keyboard attached.
- Tailscale creates a private connection between my devices without exposing the server to the public internet.
- An API is the doorway the app uses to send a question to Ollama and receive the model's answer.
I also run Ollama on my laptop, where I try models before moving one to the mini.
What you need to follow along
- An Apple Silicon Mac. That's all the laptop tier needs.
- For the server tier: a second Mac that stays on, with Remote Login enabled (System Settings > General > Sharing), Homebrew installed, and a Tailscale account signed in on both machines.
Terms are explained as they come up, and anything left over is in the glossary at the end.
I wanted a language model I could build against without usage fees, rate limits, or sending my study notes to someone else's API. I picked Ollama to run it and Qwen as the model. The harder question was where to run it, because a laptop is the wrong place for anything that has to be up when I'm not. So the model lives on a Mac mini in a closet, reached only over SSH (a remote terminal login) and Tailscale. This is what that took, including the three things that bit me.
The shape of it
Two machines, same software on both, one private network between them:
| MacBook Pro | Mac mini | |
|---|---|---|
| Chip / memory | M3 Pro, 18 GB | M4 Pro, 48 GB |
| Job | Try models, write code | Run models all day, serve one app |
| Runs | Ollama with qwen2.5-coder:7b | Ollama with qwen3:14b and nomic-embed-text |
| Uptime | When the lid is open | Always, in theory (see below) |
Ollama runs a small web server on each machine, on port 11434. At /v1 it accepts the same request format as OpenAI's API, so the standard OpenAI libraries work without changes. My app doesn't know which machine it's talking to. To switch machines, I change one address:
import OpenAI from "openai";
const llm = new OpenAI({
baseURL: process.env.LLM_BASE_URL ?? "http://localhost:11434/v1",
apiKey: "ollama", // required by the client, ignored by Ollama
});I develop against the laptop and point LLM_BASE_URL at the mini when I deploy.
Why Qwen, and which one
Qwen is Alibaba's open-weight model family, meaning the trained weights are published for anyone to download and run. Even the small sizes handle chat, code, and clean JSON an app can parse, which is what a hobby server needs. The family moves fast (Qwen3.5 and 3.6 have shipped since I set this up), so check the Ollama library before copying a tag from here.
The number that decides which one you can run is memory, not download size. Models are usually shrunk ("quantized") to 4 bits per number to save memory. After that, figure about half a gigabyte for every billion parameters, the learned numbers that make up a model:
| Model | Download | 18 GB laptop | 48 GB mini |
|---|---|---|---|
qwen2.5-coder:7b | 4.7 GB | Comfortable | Yes |
qwen3:14b | 9.3 GB | Barely, with nothing else open | Yes |
qwen3.6:27b | 18 GB | No | Yes |
qwen3:30b-a3b (MoE) | 18 GB | No | Yes |
Add the KV cache, the model's working memory for the current conversation, which grows with the context window (how much text it can consider at once), plus whatever else the machine is doing. On the laptop that's a browser and a code editor, which is why 7B is what I actually run there. On the mini, the tutor app was tested against qwen3:14b. The 30B mixture-of-experts (MoE) model, which uses only 3B of its parameters per token, is downloaded for a side-by-side comparison I haven't run yet. Changing models is a one-line settings change.
Tier one: the laptop
Nothing exotic. Install the Mac app from ollama.com/download, then:
ollama run qwen2.5-coder:7bThe first run downloads the model. After that, Ollama is listening on port 11434. On the M3 Pro it generates about 29 tokens per second, and a token is roughly three quarters of a word, so that's faster than I read.
To check the API from a second terminal:
curl http://localhost:11434/api/generate -d '{
"model": "qwen2.5-coder:7b",
"prompt": "Say hello in five words.",
"stream": false
}'localhost means this machine, and 11434 is the port, the numbered door Ollama listens on. A JSON reply with a response field means it works, and that API is what every app in this post talks to.
One thing worth looking at right away is ollama ps after a request:
NAME ID SIZE PROCESSOR CONTEXT UNTIL
qwen2.5-coder:7b dae161e27b0e 4.9 GB 100% GPU 4096 4 minutes from nowTwo numbers there matter later. CONTEXT 4096 is the window Ollama chose for this laptop, not the model's 32K limit. UNTIL 4 minutes from now is how long the model stays in memory before Ollama unloads it. Those defaults are fine on a laptop. On a server, the second one caused my slowest bug.
Tier two: the headless Mac mini
Installing without a screen
I installed Ollama on the mini over SSH with Homebrew, the Mac package manager. Then brew services start ollama failed:
Bootstrap failed: 125: Domain does not support specified actionThat's launchd, macOS's service manager, refusing. Homebrew sets Ollama to start when a user logs in, and my Mac was sitting at the login screen with nobody logged in, so there was nowhere to start it. Running ollama serve in the background by hand got me moving, until the next reboot. Later, logged in at the console, the same command succeeded, and what it installed is a LaunchAgent, a per-user service that starts when I log in. That is what runs today: Ollama 0.32 under launchd, with two memory-saving settings in its plist (flash attention and an 8-bit KV cache) that trim what the context window costs.
A LaunchAgent is only as headless as its last login. The version that starts at boot, before anyone logs in, is a LaunchDaemon. This one is untested: I haven't moved the mini to it yet, so treat it as a starting point, and replace you with your macOS username in both places:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.ollama.serve</string>
<key>ProgramArguments</key>
<array><string>/opt/homebrew/bin/ollama</string><string>serve</string></array>
<key>UserName</key><string>you</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key><string>/Users/you</string>
<key>OLLAMA_KEEP_ALIVE</key><string>24h</string>
<key>OLLAMA_FLASH_ATTENTION</key><string>1</string>
<key>OLLAMA_KV_CACHE_TYPE</key><string>q8_0</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
</dict>
</plist>brew services stop ollama # stop the login-time copy first, or two servers fight over port 11434
sudo chown root:wheel /Library/LaunchDaemons/com.ollama.serve.plist
sudo chmod 644 /Library/LaunchDaemons/com.ollama.serve.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/com.ollama.serve.plistlaunchd refuses a daemon whose plist it doesn't own, hence the chown. HOME is there because Ollama keeps models under ~/.ollama, and a daemon doesn't inherit your shell's idea of home. I haven't made this switch yet. The next section is the reason I should have.
Tailscale on a Mac with nobody logged in
Here is the part that bit me twice, most recently on September 5.
I restarted the mini at about eight in the evening. It booted, it joined Wi-Fi, and it never appeared on the tailnet (Tailscale's word for your private network of devices). From the laptop, tailscale status showed it offline while packets went out and nothing came back. It reappeared at 7:24 the next morning, the moment I logged in at the console. Eleven hours of a server that was on and unreachable.
The reason is the same as the Homebrew failure. The Tailscale from the App Store, and the standalone download the mini runs, are apps: they live inside a logged-in session. No session, no Tailscale, and the machine is on the LAN and invisible to everything else.
Two real fixes:
- Automatic login. The session exists at boot, the apps start. It works, and anyone who plugs in a monitor is already at your desktop.
- The open-source
tailscaleddaemon instead of the app. A daemon is a background service launchd starts at boot, so it doesn't care whether anyone is logged in. Tailscale's wiki describes this route as less tested and for advanced users, which I now read as "anyone bitten by option one." Expect to debug it yourself.
Quit the Tailscale app and remove it from Login Items first, so only one Tailscale client runs on the machine:
brew install --formula tailscale
sudo brew services start tailscale
sudo tailscale upAs I write this, the mini runs neither: the standalone app as a login item, and no automatic login. Option two is the plan, and those eleven hours are why it moved up the list. Ollama and the tutor app have the same flaw, and the same fix: make them daemons.
Reaching the model
By default, Ollama only accepts requests from the computer it runs on (it listens on 127.0.0.1). On the mini I leave that alone: the only client is the tutor app on the same box. If you want your laptop to use the mini's model directly, OLLAMA_HOST=0.0.0.0:11434 in the daemon's environment opens it to the LAN and tailnet, and Tailscale's MagicDNS lets you reach it as http://macmini:11434.
The app itself is published to the tailnet with Tailscale Serve, which gives the app a private HTTPS address ending in ts.net that only my devices can open:
tailscale serve --bg --https=443 localhost:5173Serve is for your tailnet. Its sibling tailscale funnel exposes the same thing to the whole internet. Don't do that in front of a model server with no authentication, which describes Ollama exactly.
Keep the model warm: the 158-second bug
The app on the mini is a quiz tutor for a certification exam. It turns the study material into embeddings (numeric fingerprints that make similar passages findable) with nomic-embed-text, finds the passages relevant to a topic, and asks qwen3:14b for a multiple-choice question grounded in them, with citations, in strict JSON. When I first used it for real, each question took about 158 seconds.
My first theory was "14B is too big for this box." Wrong. The clue was ollama ps returning nothing between questions. The model wasn't slow. It wasn't loaded. Ollama unloads a model after five minutes idle, so every question after a break paid for reading 9 GB back into memory. Then the app's own retry loop made it worse: when it ran out of fresh questions for a topic, it wrote three new drafts, threw each away for being too similar to an old question, and only then showed a backup question.
To see what each part costs, I measured a cold request (model unloaded first) against a warm one, 200 tokens of output, on both machines:
| Machine and model | Cold | Warm | Cold-start cost |
|---|---|---|---|
M3 Pro 18 GB, qwen2.5-coder:7b | 29.0 s | 7.2 s | 21.8 s |
M4 Pro 48 GB, qwen3:14b | 15.3 s | 8.1 s | 7.2 s |
Generation speed was identical cold or warm on both. On the laptop, three quarters of the cold request was loading. On the mini, with a fast SSD and plenty of free memory, loading took seven seconds. So the cold load explains only a small part of the 158 seconds. The retry loop made each question three times slower. A quiz question in strict JSON is several hundred tokens at 25 tokens per second, behind a few thousand tokens of retrieved context, and the app was doing that three times before showing me anything. Back of the envelope, not measured: at roughly 50 seconds a draft, three drafts plus a 7-second cold load comes to about 158.
Two fixes, both configuration. The tutor now sends keep_alive: "24h" with every request, so the model stays in memory. And retries went from three to two, with the next question prepared in the background while I read the current one. The server-wide equivalent is OLLAMA_KEEP_ALIVE in Ollama's own environment, which the plist above sets. Now ollama ps on the mini looks like a server:
NAME ID SIZE PROCESSOR CONTEXT UNTIL
qwen3:14b bdbd181c33f2 12 GB 100% GPU 32768 24 hours from nowThat per-request field is where one gotcha cost an evening. The docs say -1 means forever. I sent it as a string, and every request failed:
{ "model": "qwen3:14b", "keep_alive": "-1" } // rejected: "missing unit in duration"
{ "model": "qwen3:14b", "keep_alive": -1 } // a number: fine
{ "model": "qwen3:14b", "keep_alive": "24h" } // a duration with a unit: fineA string is parsed as a Go duration, which needs a unit like 10m or 24h, and -1 has none. My app only reported a generic "model call failed" error, nowhere near the line that caused it.
The context column has a twist too. The laptop showed 4096 and the mini shows 32768, with no configuration on my part either time: since early 2026 Ollama picks the default window from the memory it can give the GPU (4K below 24 GB, 32K from 24 to 48 GB, 256K from 48 GB up). So the same model gets a different window on each tier unless you pin it with OLLAMA_CONTEXT_LENGTH or a per-request num_ctx. And the window isn't free: the 9.3 GB download occupies 12 GB loaded, the difference being the KV cache for 32K tokens, even with the 8-bit cache setting. Fine on 48 GB. On the 18 GB laptop, 14B at 32K is the difference between running and swapping, which is presumably why Ollama doesn't default to it there.
Why not vLLM
The tutorial version of this post would now move the model to vLLM, a model server built for many users at once on big NVIDIA graphics cards. My server has one user most of the time. Ollama handles only a few requests at once, but that was never my problem. The sleeping model and my retry loop were. A community vllm-metal plugin now runs vLLM on Apple Silicon, so if requests ever start queuing, I'll try it, and the one-address switch means the app is ready.
What I'd tell you
- Two tiers, one API. Try models on the laptop, run them on something that stays on, and make a base URL the app's only knowledge of the difference.
- On a headless Mac, anything that starts at login is off until someone logs in. Make Ollama, Tailscale, and your own app daemons, before the reboot you didn't plan.
- A server's defaults are not a laptop's defaults. Keep the model in memory with
OLLAMA_KEEP_ALIVEon the server orkeep_aliveon each request. Checkollama pson each machine, because the default context window depends on its memory. And measure cold against warm before blaming the model. keep_alive: "-1"is notkeep_alive: -1. Send a number, or a duration with a unit. And Serve, never Funnel: Ollama has no auth, and your tailnet is the boundary.
Glossary
- MoE (mixture of experts): a model that has many parameters but uses only a fraction of them per token, so it runs faster than its size suggests while still needing memory for all of them.
- KV cache: the working memory a model builds up for the current context; it grows with the context window and lives alongside the model's weights.
- launchd / LaunchDaemon / LaunchAgent: macOS's system for starting and supervising background programs. A LaunchDaemon starts at boot, before anyone logs in; a LaunchAgent starts when a user logs in.
0.0.0.0: every network interface. A service bound there answers anything that can reach the machine, such as the LAN or tailnet.- LAN: your local network, the devices behind your home router.
- Environment variable: a named setting handed to a program when it starts, like
OLLAMA_KEEP_ALIVE=24h. - Base URL: the address an API client puts in front of every request, like
http://localhost:11434/v1; changing it points the app at a different server. - plist: the XML settings format macOS uses, including the files that tell launchd what to run.
launchctl: the command-line tool for loading, starting, and stopping launchd services.- Swapping: when programs need more memory than the machine has free and the OS moves some to disk, so everything slows to a crawl.
- Tailnet: your own set of devices on Tailscale, reachable by each other as if on one LAN.
- MagicDNS: Tailscale's feature that gives each device a name, so
macminiresolves without remembering an IP. - Tailscale Serve / Funnel: Serve publishes a local port to your tailnet over HTTPS; Funnel publishes it to the public internet.