What Memos asks of the machine
The Memos docs publish no CPU or memory minimum. They call the application lightweight and put the limits in the database layer. The table is the docs; the advice after it is ours.
| Item | Per the Memos docs |
|---|---|
| Image | neosmemo/memos; tags stable, exact versions such as 0.31.0, latest for development |
| Port | 5230 in the Docker image; 8081 for the plain binary |
| Architectures | linux/amd64, linux/arm64, linux/arm/v7 |
| Database | SQLite by default, in WAL mode; MySQL or PostgreSQL through MEMOS_DRIVER and MEMOS_DSN |
| SQLite limits | One concurrent writer, any number of readers, practical size up to about 100 GB |
| Attachments | In the database by default; local filesystem or S3-compatible storage optional |
| Container user | UID 10001, GID 10001, named nonroot |
Our reading: one Memos container on SQLite is a small process guarding one file. For one person or a small team, a sound disk and a tested copy of that file matter more than core count. The docs point to PostgreSQL or MySQL when you want managed database tooling, already operate a database, or expect larger datasets.
Which machines fit
The dedicated server range runs from single-job Moonshot cartridges to four-socket machines, each one rented whole, root included, its port unmetered. For Memos it splits three ways, in our judgement:
- A Moonshot cartridge for one instance. The service page pitches cartridges at one service per machine, which is what Memos is. Of the cartridges, only the m510 and m710x carry an out-of-band console, and the rest stop at power control.
- A single-socket rack server when Memos shares the box with its own PostgreSQL and a few other services. Every racked single-, dual- and quad-socket server has a full out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC), which is how you get back in after a firewall mistake.
- The Ampere Altra line on ARM runs the image's linux/arm64 variant. Leave out the X-Gene cartridge: it boots only Ubuntu 16.04 and 18.04, and the steps here assume a current Ubuntu release.
A ready server takes about 30 minutes to go live once payment is approved; a built-to-order line takes 4 to 24 hours. Choose the city nearest the people who will write notes from the five locations.
Firewall first, then the container
Set up ufw before anything listens. ufw writes IPv6 copies only for rules added while IPV6=yes, so that line comes first.
sudo sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufwsudo ufw allow OpenSSHkeeps your session.sudo ufw --force enable. The plain form stops for a y/n answer, and in a pasted block the next line becomes that answer.sudo ufw allow 80,443/tcpadmits the proxy that comes later.- Install Docker Engine plus the Compose plugin as Docker documents it, run
sudo mkdir -p /opt/memos/data, and save the file below as /opt/memos/compose.yaml. sudo docker compose -f /opt/memos/compose.yaml up -d, thensudo docker compose -f /opt/memos/compose.yaml logs memosto confirm it started cleanly.
services:
memos:
image: neosmemo/memos:0.31.0
container_name: memos
restart: unless-stopped
ports:
- "127.0.0.1:5230:5230"
volumes:
- /opt/memos/data:/var/opt/memos
environment:
MEMOS_PORT: 5230
MEMOS_DRIVER: sqlite
# uncomment once the admin account exists:
# MEMOS_INSTANCE_URL: https://memos.example.com
A port mapping with no address, like the docs' 5230:5230, is published on every host address, 0.0.0.0 and [::]. Docker's rules let that traffic past ufw on IPv4, and on IPv6 only once Docker's own IPv6 networking is switched on. The 127.0.0.1: prefix keeps 5230 on the loopback, which since Docker Engine 28 is closed to other hosts. MEMOS_INSTANCE_URL stays commented out on purpose: on a fresh instance, the docs say a configured URL initialises public access and an empty one initialises private access.
The admin account, through your own tunnel
The docs state that the first user becomes the instance admin. The source agrees: CreateUser in server/api/user_service.go assigns the admin role when the user table is empty. Whoever reaches the sign-up form first owns the instance, so create that account while 5230 is still loopback-only.
- From your own computer:
ssh -L 5230:127.0.0.1:5230 you@your-server. - Open http://localhost:5230 in a local browser and register with a strong password. That account is the admin.
- In the instance settings, tick Disallow user registration. That label comes from the web app's English strings in the repository; the docs' deployment JSON names the same switch
disallowUserRegistration. - Check that instance access is private. If nothing should ever be public, also disable PUBLIC memo visibility, which the docs' FAQ says the instance settings allow.
Private access sends anonymous visitors to sign-in, but the docs list registration, when allowed, among the endpoints that stay open to them, so closing registration is the step that keeps strangers from making accounts. Explicit share links still open their one memo without sign-in until revoked or expired.
HTTPS in front: DNS, then certificate, then URL
The docs ask for a reverse proxy that terminates TLS, preserves Host and the X-Forwarded-* headers, and serves the same address MEMOS_INSTANCE_URL holds. They also ask proxies not to buffer the live-update event stream. With nginx on the host, the order is:
- Create an A record for memos.example.com with the server's IPv4 address (and an AAAA with its IPv6 address, if it has one), then wait until both resolve.
sudo apt install -y nginx certbot python3-certbot-nginx. Save the block below as /etc/nginx/sites-available/memos, link it into /etc/nginx/sites-enabled, thensudo nginx -t && sudo systemctl reload nginx. It serves plain HTTP and names no certificate yet.sudo certbot --nginx -d memos.example.comobtains the certificate first, then writes the HTTPS lines into that file itself.- Uncomment MEMOS_INSTANCE_URL in compose.yaml, recreate the container with
sudo docker compose -f /opt/memos/compose.yaml up -d. Confirm access is still private: per the docs, changing the URL never changes an existing access policy.
server {
listen 80;
listen [::]:80;
server_name memos.example.com;
client_max_body_size 32m;
location / {
proxy_pass http://127.0.0.1:5230;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
Two lines in that block are ours, not the docs'. proxy_buffering off is our nginx form of the docs' rule against buffering the event stream. client_max_body_size is our addition: nginx's default body limit is 1 MB, which would refuse larger uploads, so match it to the upload limit you allow in Memos. nginx is an ordinary host process, so it depends on the ufw rule for 80 and 443, which IPV6=yes made cover both address families.
Backups, and one restore to prove them
Everything is in /opt/memos/data: memos_prod.db and, once attachments go to local storage, an assets directory. The docs give two methods: SQLite's own .backup command, which copies the database correctly in WAL mode while Memos runs, and a stop-and-copy of the whole directory. The files belong to UID 10001, so every command runs with sudo. The block creates the backup directory first, takes an online copy, checks it, archives local attachments if there are any (.backup copies only the database), and starts a throwaway container on the copy.
sudo install -d -m 700 /var/backups/memos
sudo apt install -y sqlite3
f=/var/backups/memos/memos-$(date +%F).db
sudo sqlite3 /opt/memos/data/memos_prod.db ".backup '$f'"
sudo test -s "$f" && sudo sqlite3 "$f" "PRAGMA integrity_check;"
if sudo test -d /opt/memos/data/assets; then sudo tar -czf /var/backups/memos/assets-$(date +%F).tar.gz -C /opt/memos/data assets; fi
sudo install -d /tmp/memos-restore
sudo cp "$f" /tmp/memos-restore/memos_prod.db
sudo docker run --rm -d --name memos-restore -p 127.0.0.1:5231:5230 -v /tmp/memos-restore:/var/opt/memos neosmemo/memos:0.31.0
integrity_check prints ok when the copy is a sound SQLite file. That proves the file, not the restore. The container on 5231 proves that Memos opens it: tunnel 5231 the same way as before, sign in, open a few memos, then sudo docker stop memos-restore and sudo rm -r /tmp/memos-restore.
The docs' offline method, with /var/backups/memos already in place: sudo docker compose -f /opt/memos/compose.yaml stop memos, then sudo tar -czf /var/backups/memos/data-$(date +%F).tar.gz -C /opt/memos data, then sudo docker compose -f /opt/memos/compose.yaml start memos. Confirm the archive reads back with if sudo tar -tzf /var/backups/memos/data-YYYY-MM-DD.tar.gz > /dev/null; then echo archive readable; fi.
For a nightly copy, one line in /etc/cron.d/memos-backup is enough: `15 3 * * * root umask 077; install -d -m 700 /var/backups/memos && sqlite3 /opt/memos/data/memos_prod.db ".backup '/var/backups/memos/memos-$(date +\%F).db'" && if [ -d /opt/memos/data/assets ]; then tar -C /opt/memos/data -czf /var/backups/memos/assets-$(date +\%F).tar.gz assets; fi. Cron treats a bare % as a line break, hence \%`. Prune old copies, and copy /var/backups/memos off the machine to storage you control: a backup on the same disks fails with them. On MySQL or PostgreSQL the docs use mysqldump or pg_dump instead, and attachments kept outside the database need their own copy.
Putting a backup back on the live instance
The same sequence serves a real restore and an upgrade rollback. Work from a copy that passed the checks above, and keep the current directory until the restored one is proven.
- Point
fat the copy, for examplef=/var/backups/memos/memos-YYYY-MM-DD.db, and runsudo test -s "$f" && sudo sqlite3 "$f" "PRAGMA integrity_check;". Stop here unless it prints ok. sudo docker compose -f /opt/memos/compose.yaml stop memos.sudo mv /opt/memos/data /opt/memos/data.old, thensudo install -d /opt/memos/data.sudo cp "$f" /opt/memos/data/memos_prod.db. If attachments live on local disk, add them back withsudo tar -xzf /var/backups/memos/assets-YYYY-MM-DD.tar.gz -C /opt/memos/data. The new directory starts empty, so no stale memos_prod.db-wal or -shm file sits beside the restored database.sudo chown -R 10001:10001 /opt/memos/data.- Set the image tag in compose.yaml to the version that took the backup (the previous tag, when rolling back an upgrade), run
sudo docker compose -f /opt/memos/compose.yaml up -d, sign in and check memos and attachments. Only thensudo rm -r /opt/memos/data.old.
Upgrades: pinned, backed up, one way
Memos runs its database migrations on start, and the docs say downgrading after a migration is not supported: the backup is the rollback path. The stable tag upgrades implicitly on every pull, so pin an exact tag (0.31.0 is the latest stable release on the project's GitHub releases page as this is written) and move it deliberately.
- Back up as above.
- Read the changelog for every version you skip. The docs warn that historical minor releases (0.x to 0.y) and the newer calendar releases can both contain breaking changes.
- Change the tag in compose.yaml, run
sudo docker compose -f /opt/memos/compose.yaml pullandsudo docker compose -f /opt/memos/compose.yaml up -d, thensudo docker compose -f /opt/memos/compose.yaml logs memosto confirm it started cleanly. - Check sign-in, an existing memo, a new memo and an attachment.
The docs' path to the 26.10 calendar release, a release candidate at the time of writing, runs through 0.31.0: from 0.22 to 0.30, start 0.31.0 successfully first; from anything older, run 0.25.3, then 0.31.0. 26.10 also moves API clients from /api/v1/ to /api/. To roll back, follow the restore steps above with the pre-upgrade backup and the previous tag; never point an older version at a database a newer one has migrated.
Where this setup stops
SQLite here means one node: the docs fit it to one instance handling all writes, and send several writers or replicas to MySQL or PostgreSQL. For a sync target behind desktop and phone note apps, Joplin Server is the closer fit; for documents several people edit at once, see HedgeDoc.
Questions
Which port does the Memos Docker image use?
5230. The image sets MEMOS_PORT to 5230 and exposes it; the plain binary defaults to 8081. In this setup the mapping keeps it on 127.0.0.1, and nginx is the one thing that talks to it.
Is SQLite enough, or should it be PostgreSQL?
The docs call SQLite the best fit for most personal deployments and small teams on a single node. PostgreSQL or MySQL make sense when you already operate one or expect larger datasets. A DSN carries the database password, so run sudo chmod 600 /opt/memos/compose.yaml and only root can read it; the image also accepts MEMOS_DSN_FILE, pointing at a file that holds the string.
What does the MIT licence allow?
Memos ships under the MIT License, per the repository's LICENSE and the FAQ in the docs. Our general reading, not legal advice: you may use, copy, modify and distribute it, and whoever distributes copies or modified versions must pass the licence's copyright and permission notice along with them. Hosting it for yourself distributes nothing.