Deploy
One-click cloud buttons, Docker, Compose, source builds, and backups.
Prerequisites
- Docker deploys: Docker Engine on any Linux box, NAS, Raspberry Pi, or VPS. No repository clone needed — the image is on GitHub Container Registry.
- Source deploys:
git, Node.js 22+, pnpm. - One-click cloud deploys: just an account with the provider.
One-click cloud deploys
NemoMemo is a stateful app (SQLite + uploads on disk), so it needs a host with a persistent volume — which is why you won't see Vercel/Netlify buttons here; those platforms are for static and serverless apps and would lose your data.
- Render — the button reads this repo's
render.yaml: one Docker web service with a 1 GB persistent disk mounted at/app/dataand a health check, preconfigured. Persistent disks require Render's paid Starter instance (about $7/month). Sign in, click Apply, done. - Koyeb — the button prefills a Dockerfile build from this repo. After the first
deploy, attach a volume to the service mounted at
/app/datain the Koyeb dashboard (Volumes → attach) so your data survives redeploys.
Both providers inject a PORT environment variable, which NemoMemo respects
automatically.
Docker
docker run -d --name nemomemo \
-p 5230:5230 \
-v nemomemo-data:/app/data \
ghcr.io/davidallmon/nemomemo:latestAdd --env-file .env for any of the optional settings below.
Everything lives in the /app/data volume: nemomemo.db (SQLite) and uploads/.
Images are published for amd64 and arm64 (Raspberry Pi 4/5 works). Tags: latest
tracks main; version tags appear on releases.
Upgrading:
docker pull ghcr.io/davidallmon/nemomemo:latest
docker rm -f nemomemo && docker run -d --name nemomemo \
-p 5230:5230 -v nemomemo-data:/app/data ghcr.io/davidallmon/nemomemo:latestDocker Compose
services:
nemomemo:
image: ghcr.io/davidallmon/nemomemo:latest
ports:
- "5230:5230"
volumes:
- nemomemo-data:/app/data
env_file: [.env] # optional — delete this line if you have no .env
restart: unless-stopped
volumes:
nemomemo-data:Build from source
git clone https://github.com/DavidAllmon/nemomemo.git
cd nemomemo
pnpm install --filter '!@nemomemo/site'
pnpm build
NEMOMEMO_WEB_DIST=web/dist node server/dist/index.jsOr build the image yourself: docker build -t nemomemo . (the image contains only the
app — never the marketing/docs site).
Configuration
Nothing here is required — NemoMemo runs with no configuration at all. When you do want something, it all comes from one env file. Start from the annotated template in the repo, which lists every setting below with its default, commented out:
curl -fsSLO https://raw.githubusercontent.com/DavidAllmon/nemomemo/main/.env.example
cp .env.example .envThen apply it: --env-file .env on docker run, env_file: [.env] on a Compose
service, NEMOMEMO_ENV_FILE=.env for the install script, or
set -a; . ./.env; set +a from source. Recreating a container to pick up new
variables is safe — your memos live in the data volume, not the container.
Docker and Compose users: leave the first three alone. The image already sets
NEMOMEMO_PORT, NEMOMEMO_DATA and NEMOMEMO_WEB_DIST correctly. Pointing
NEMOMEMO_DATA at a relative path writes your memos inside the container rather
than the mounted volume, and the next upgrade throws them away. To serve on a
different port, change the mapping (-p 8080:5230) instead. Those three are for
from-source installs.
| Env var | Default | Purpose |
|---|---|---|
NEMOMEMO_PORT (or PORT) | 5230 | HTTP port |
NEMOMEMO_DATA | ./data | Data directory |
DORY_TTL_SECONDS | 86400 | How long Dory remembers |
NEMOMEMO_WEB_DIST | — | Path to the built SPA (source installs) |
NEMOMEMO_SMTP_HOST | — | SMTP server for outbound email (optional — needs all four of HOST/USER/PASS/FROM; see below) |
NEMOMEMO_SMTP_PORT | 587 | SMTP port (465 switches to implicit TLS) |
NEMOMEMO_SMTP_USER | — | SMTP login |
NEMOMEMO_SMTP_PASS | — | SMTP password / API key |
NEMOMEMO_SMTP_FROM | — | From address, e.g. "NemoMemo <[email protected]>" |
NEMOMEMO_OCR | 1 | Read text inside image attachments so search finds it — set 0 to turn off |
NEMOMEMO_OCR_LANGS | eng | OCR languages, comma-separated tesseract codes (e.g. eng,deu) |
NEMOMEMO_TRANSCRIBE_URL | — | OpenAI-compatible /audio/transcriptions endpoint for voice memo transcripts (optional — see below) |
NEMOMEMO_TRANSCRIBE_KEY | — | API key for that endpoint (omit for keyless local servers) |
NEMOMEMO_TRANSCRIBE_MODEL | whisper-1 | Model name sent to the endpoint |
NEMOMEMO_DICTATE_KEY | — | OpenAI API key that turns on live dictation in the editor (optional — see below) |
NEMOMEMO_DICTATE_MODEL | gpt-live-transcribe | OpenAI Realtime transcription model for dictation |
NEMOMEMO_TELEGRAM_BOT_TOKEN | — | Turns on the Telegram capture bot (optional — see below) |
Image text search (OCR)
Since v1.19, search can read your screenshots: when someone attaches an image, the reef quietly extracts any text in it (receipts, whiteboards, signs) and adds it to the search index. It runs entirely on your server — no cloud API, nothing leaves the reef.
The first image after a fresh install downloads the OCR language data (a few MB)
into data/ocr-cache and reuses it from then on. If your reef runs air-gapped
with no outbound network, set NEMOMEMO_OCR=0 — everything else works exactly
the same, images just aren't searchable by their contents.
Live dictation (optional)
Since v1.21 the editor has a microphone button: tap it, talk, and your words appear in the memo as you speak — tap again to stop. It needs an OpenAI API key, because dictation uses OpenAI's Realtime transcription service:
NEMOMEMO_DICTATE_KEY=sk-...Your API key stays on your server. When someone starts dictating, the reef mints a single-use key that expires in minutes, and the browser streams audio straight to OpenAI with only that. Audio is transcribed live and isn't stored by your reef — only the text you keep in the memo.
Without a key, the microphone button records a voice clip instead (no setup, no external services) — the audio attaches to your memo and plays back inline. With dictation on, voice clips move to the little arrow next to the microphone, so you can always do either.
Session requests are capped at 20 per 10 minutes per IP — far more than anyone tapping the mic will reach, and enough to stop a script. That limits how many sessions get minted, though: audio streams from the browser straight to the provider, so if your reef is open to the public, put a spend cap on the API project too.
Voice transcription (optional)
Since v1.20 you can record voice memos right in the editor — they play back
inline with no setup at all. Point NEMOMEMO_TRANSCRIBE_URL at any
OpenAI-compatible /audio/transcriptions endpoint and the reef also
transcribes each recording: the transcript appears under the player and the
words become searchable, just like text you typed.
# OpenAI:
NEMOMEMO_TRANSCRIBE_URL=https://api.openai.com/v1/audio/transcriptions
NEMOMEMO_TRANSCRIBE_KEY=sk-...
# …or a local whisper.cpp / LocalAI / Speaches server on your own hardware:
NEMOMEMO_TRANSCRIBE_URL=http://localhost:8080/v1/audio/transcriptionsLeave it unset and voice memos still record and play — they just aren't searchable by what was said.
Capture from Telegram (optional)
Since v1.31, your reef can have a Telegram bot: message it and what you send
becomes a memo — text, photos, and voice notes, with #tags working inline.
It's the cheapest way to capture from a phone, because there's no app to
install and you're already signed in.
Make a bot, then point your reef at it:
- Message @BotFather on Telegram and send
/newbot. - Pick a name and a username; BotFather replies with a token that looks like
123456789:AAE…. - Set it and restart:
NEMOMEMO_TELEGRAM_BOT_TOKEN=123456789:AAE...Each member then connects their own chat: Settings → Access → Connect
Telegram hands them a code, and they send /link CODE to the bot. One bot
serves the whole reef — nobody shares anything with anyone else, and memos land
in the chatter's own reef with their default visibility. /unlink in the chat
(or Disconnect in Settings) ends it immediately.
Photos go through OCR and voice notes through transcription if you've turned those on above, so a captured whiteboard is searchable by what's written on it.
Messages to a Telegram bot travel through Telegram's servers and are not end-to-end encrypted — that's how the Bot API works, not something NemoMemo chooses. It's fine for "buy milk on the way home"; keep genuinely private things in the app itself. Your bot token is a credential too: anyone holding it can read messages sent to your bot, so treat it like a password.
Only single-instance (self-hosted) reefs run the bot: the hosted service shares one process across many reefs, and several pollers on one token would fight over Telegram's update queue.
Email (optional, recommended)
Set all five NEMOMEMO_SMTP_* variables and your reef can send email — any SMTP
provider works (Brevo and Postmark have friendly free tiers, or your own relay):
NEMOMEMO_SMTP_HOST=smtp-relay.example.com
NEMOMEMO_SMTP_PORT=587
NEMOMEMO_SMTP_USER=[email protected]
NEMOMEMO_SMTP_PASS=your-smtp-key
NEMOMEMO_SMTP_FROM="NemoMemo <[email protected]>"With email on, your reef gets: welcome + verification emails for new members, self-serve password reset ("Forgot your password?" on the sign-in page), member invites (add someone by email and they pick their own password), and security heads-ups when a password or email changes.
Without it, everything still works: accounts still sign up with an email address (it's part of every account since v1.8), but nothing sends — members who forget a password ask their reefkeeper, who can set a new one in Settings → Members.
Exposing it to the internet
Put NemoMemo behind HTTPS — either a reverse proxy (Caddy, nginx, Traefik) terminating
TLS in front of port 5230, or a Cloudflare Tunnel pointing at
http://localhost:5230 (no open ports needed; great for homelabs).
Backups
Your whole reef is two things inside the data volume: nemomemo.db (SQLite) and
uploads/. Back up both. (On NemoMemo Cloud we do this for you nightly — self-hosting
means this part is yours.)
The easy way: the button
As the reefkeeper (admin), open Settings → Backups → Download backup. You get one zip containing a consistent database snapshot (safe even while people are writing) and every uploaded file. Do that on a schedule you'll actually keep, store the zip somewhere that isn't the server, and you're covered. Everything below is for automating it.
The readable way: Markdown export
Any memo can be saved as a plain .md file from its ⋯ → Copy → Download as .md
menu — created/updated times, visibility, and tags ride along as YAML frontmatter,
so it reads correctly in any Markdown app. Power users can grab everything at once:
while signed in, GET /api/v1/memos/export/markdown streams a zip of all your own
memos — one dated .md file each, comments in their own folder, plus an
attachments/ folder with inline links rewritten to relative paths. Either way it's
a copy for humans and other tools, not a restore format — the backup zip above is
the one the Restore button accepts.
A safe snapshot (Docker)
Don't copy nemomemo.db while the app is running — a plain cp can catch it
mid-write. SQLite's .backup takes a consistent snapshot even while live:
mkdir -p ~/nemomemo-backups
docker exec <container> sh -c \
'apk add --no-cache sqlite >/dev/null 2>&1 || true; sqlite3 /app/data/nemomemo.db ".backup /app/data/backup.db"'
docker cp <container>:/app/data/backup.db ~/nemomemo-backups/nemomemo-$(date +%F).db
docker cp <container>:/app/data/uploads ~/nemomemo-backups/uploads-$(date +%F)
docker exec <container> rm /app/data/backup.db(Running from source instead? Just point sqlite3 and cp at your data directory
directly.)
Make it nightly
Put those commands in a script and add a cron line on the host:
17 3 * * * /home/you/backup-nemomemo.sh >> /home/you/nemomemo-backups/backup.log 2>&1Off the machine (recommended)
A backup on the same disk dies with the disk. restic — the same tool NemoMemo Cloud's own backups use — encrypts your backup folder and pushes it to almost anywhere: another machine over SFTP, or any S3-compatible bucket.
Getting a free bucket (either of these comfortably fits a reef in its free tier):
- Cloudflare R2 (pricing: 10 GB +
free egress): in the Cloudflare dashboard go to R2 → Create bucket (pick any
name, e.g.
my-reef-backups). Then R2 → Manage R2 API Tokens → Account API Tokens → Create: permissions Object Read & Write, apply to your bucket only, TTL Forever. The confirmation screen shows an Access Key ID, Secret Access Key, and your account's S3 endpoint URL — copy all three (the secret is shown only once). - Backblaze B2 (pricing: first 10 GB always free): create a bucket, then Application Keys → Add a New Application Key scoped to that bucket; the key ID/key are your S3 credentials and the endpoint is shown on the bucket page.
Then wire restic to it:
export AWS_ACCESS_KEY_ID=<access key id>
export AWS_SECRET_ACCESS_KEY=<secret access key>
export RESTIC_REPOSITORY=s3:https://<endpoint>/<bucket>
export RESTIC_PASSWORD=<pick a strong passphrase and KEEP IT SAFE>
restic init # once
restic backup ~/nemomemo-backups # each night (add to your cron script)
restic forget --keep-daily 14 --keep-weekly 8 --prune # retentionWithout the restic passphrase a backup can never be restored — store it (and the keys) somewhere that isn't this server, like a password manager.
Restoring
The easy way: as the reefkeeper, Settings → Backups → Restore from backup — upload a backup zip, and the reef verifies it, sets the current data aside as a safety copy, swaps the backup in, and restarts itself. Done.
By hand (if the app won't start, or you prefer the shell): stop the container, put the files back, start it again:
docker stop <container>
docker cp ~/nemomemo-backups/nemomemo-2026-08-22.db <container>:/app/data/nemomemo.db
docker cp ~/nemomemo-backups/uploads-2026-08-22/. <container>:/app/data/uploads
docker start <container>Backups only matter if restore is realistic — do one practice restore before you depend on it. Just keep swimming (with copies). 🐟