
Memo's backend is a headless Go REST API — the same one that runs on your desktop, just without a Flutter window attached to it. That means it can run unattended on a Raspberry Pi, an old laptop, a home NAS, or a VPS, and you connect to it from your actual desktop/mobile Memo app (or a browser) elsewhere, exactly like a remote backend. Everything below is new as of v3.5.5.
There are three ways to get a self-hosted Memo running. Pick one:
| Method | Best for | What it installs |
|---|---|---|
| Server-only installer | A Raspberry Pi, home server, or VPS with SSH access | Backend + CLI + engine binaries, managed via memo and systemd |
| Docker / CasaOS | CasaOS, Unraid, Synology, or any Docker host | A single backend-only container, no shell access needed |
| Full desktop install, pointed at as a remote | You already run Memo on a desktop and want another device to reach it | The regular desktop app, with Remote Access turned on in Settings |
All three end up running the exact same backend code, so auth, memory, providers, and every feature below behave identically regardless of which one you picked.
get-memo-server.sh is get-memo.sh's headless sibling: same release archives, same ~/.memo layout, same PATH wrapper — but it deliberately skips the Flutter desktop binary, its assets, and the app-menu entry. It auto-detects your OS and architecture (Linux x86_64, Linux arm64, or macOS) from one command:
curl -fsSL https://download.bugradev.com/get-memo-server.sh | bash
Beta channel (tracks every push to main — this is currently the only way to try new self-hosted features before they land in a tagged release):
curl -fsSL https://download.bugradev.com/get-memo-server-beta.sh | bash
During a fresh install, the script asks whether to set it up as a systemd --user service (recommended — it survives crashes and, with loginctl enable-linger, reboots with no one logged in):
Run Memo as a background service (systemd --user)?
Bind to 0.0.0.0 so other devices can reach it? [Y/n]
Answering yes to the bind prompt runs the backend with --lan (binds 0.0.0.0, requires a credential on every request — see Authentication Modes below); answering no keeps it on 127.0.0.1 only, reachable solely from the box itself. Either way, once it's running the installer tells you exactly where to point a browser:
➜ Open http://<lan-ip>:8090 in your browser to get started
Running it again later (same URL) detects the existing install and updates in place — binaries and the engine refresh, config/memory/models/sessions/providers/skills are preserved, and the systemd service (if any) restarts automatically with the new binary.
# Update (works for either channel — re-run the script you originally used)
curl -fsSL https://download.bugradev.com/get-memo-server.sh | bash
# Remove a server-only install (systemd unit, ~/.memo, CLI wrapper — offers a backup first)
curl -fsSL https://download.bugradev.com/uninstall-selfhosted.sh | bash
uninstall-selfhosted.sh stops and removes the memo.service systemd unit if present, kills any manually-started --headless process, deletes ~/.memo, the ~/.local/bin/memo wrapper, and the PATH lines it added to your shell rc files — offering to back up memory/+sessions/ first. Pass -y/--yes to skip the confirmation prompt (scripted teardowns).
A backend-only, multi-arch (amd64 + arm64) image publishes automatically to GHCR on every push:
docker pull ghcr.io/bugraakdemir/memo-backend:latest
Tag scheme matches the rest of Memo's release pipeline: :latest and :vX.Y.Z only move on a real tagged release; :beta tracks every push to main.
CasaOS: App Store → Install a customized app → paste the compose file below → adjust the volume path if you want it elsewhere → Install.
Plain Docker Compose works identically without CasaOS:
name: memo
services:
memo:
image: ghcr.io/bugraakdemir/memo-backend:latest
container_name: memo
restart: unless-stopped
ports:
- "8090:8090"
volumes:
- /DATA/AppData/memo:/memo
environment:
- TZ=Europe/Istanbul
docker compose up -d
The container always binds 0.0.0.0:8090 (a loopback-only bind is unreachable through Docker's own port mapping) and requires an X-Memo-Token on every request as a result — including from other containers or localhost. The token is generated on first boot and persisted into the mounted volume; read it from the logs:
docker logs memo | grep "X-Memo-Token required"
The image bundles the CPU llama.cpp backend only — local GGUF models work, just at CPU speed. For anything beyond a small model, connecting an external provider (OpenAI, Claude, Gemini, OpenRouter, …) in Settings after first boot is the realistic choice on typical NAS/home-server hardware. Voice transcription (Whisper) isn't bundled in this image to keep it small.
Building your own image (only needed for a fork or an unpushed local change):
docker build -t <your-registry>/memo-backend:latest .
docker push <your-registry>/memo-backend:latest
Once a backend binds to anything other than 127.0.0.1 (--lan, Docker, or Remote Access on desktop), every request needs a credential — except from the machine's own loopback interface, which is always trusted in every mode (the desktop app talking to its own local backend was never meant to be gated). Four modes, switchable anytime:
| Mode | Who can connect | How |
|---|---|---|
none |
Anyone who can reach the port | No credential at all — only sensible on a fully trusted private network |
token (default) |
Any device holding a valid per-device token | X-Memo-Token header or Authorization: Bearer <token> |
password |
Anyone with the account password | Username + password login, issues a signed session |
token_password |
Both of the above, either works | — |
memo remote set-mode none
memo remote set-mode token
memo remote set-mode password --username you --password ...
memo remote set-mode token_password --username you --password ...
Passwords are hashed with argon2id, login attempts are rate-limited with a brute-force lockout, and sessions are short-lived signed tokens (12 hours by default, 30 days with "remember me"). Per-device tokens are hashed at rest, shown once at creation, and revocable individually without touching any other device's access:
memo remote list-devices
memo remote add-device "my-phone"
memo remote revoke-device <id>
memo remote rotate-token <id> # revoke + re-add under the same name, for a leaked token
If --password is left off set-mode/login, it's prompted for interactively with the input hidden — never typed as a plain, ps-visible command-line argument.
Self-hosted Memo supports multiple accounts with admin/user roles, managed from Settings → Accounts (add, remove, change password, sign out) on the desktop or web client — not just a single shared login. Every screen goes through the same first-run setup gate and login gate.
Every self-hosted backend — server-only install or Docker — serves its own web UI at its own port (http://<host>:8090 by default): a real Flutter web build, the same app you'd get on desktop, not a separate hand-rolled client. First visit walks you through setup (or a token/login screen if auth is already configured); after that you get the full chat experience plus Settings for connecting providers and managing models.
The full Flutter desktop app can also point at a self-hosted backend directly — Settings → Remote Access → Backend Server URL — for the complete feature set (memory, Orchestra, Calendar, WhatsApp, and everything else that isn't in the lighter web build).
Everything Settings can do to a self-hosted install, the memo CLI can do too — see the full CLI Reference for exact syntax. The essentials:
memo service status # is it running?
memo service restart # restart it (always --user, see below)
memo remote status # auth mode, addresses, warnings
memo config get llama.port # read config.yaml
memo config set llama.port 8081 # write config.yaml
The service is always a systemd --user unit (no root/sudo needed to install or manage it). If you use plain systemctl yourself, include --user too — bare/sudo systemctl targets system-wide units and fails with Unit memo.service not found.
memo remote/memo service manage auth and the systemd unit, but starting or stopping a tunnel itself isn't a CLI subcommand yet.docker pull on a Raspberry Pi already resolves the correct arm64 image regardless.See Known Issues for the full, current list.