Memo
Installation
Sign in Create an account
Administration

Installation

Memo is one process and one file. There is no database server to install, no build step, and nothing that has to sit beside the binary.

Every board, every account, every picture and every share link lives in one SQLite file. Backing up an instance is copying that file, and moving an instance to another machine is copying it there.

Starting it

A release binary needs nothing beside it:

./memo --port 8088 --db /var/lib/memo/memo.sqlite

From a checkout, with Bun:

bun install
bun run start

The database appears on the first run, and the server prints the address it is listening on. The first account made on an install with no admin becomes the admin, because an install nobody can administer has no way to make one.

Behind a reverse proxy

Terminate TLS at the proxy and pass everything through, /ws included. Two things the proxy has to get right:

  • The WebSocket upgrade on /ws, with a read timeout long enough for an idle board. A minute is not enough. People leave a board open all afternoon.
  • --origin https://memo.example, the public address. Every CSRF and WebSocket origin check compares against it. Without it the check falls back to the host the request claimed, which still stops a cross-site form post but trusts whatever is in front.

--baseurl /memo serves the whole thing under a sub-path when it shares a host with something else.

NixOS

The flake builds the same binary and ships a module that declares the rest of a deployment: the service, a reverse proxy, a certificate, a nightly backup and an update timer.

services.memo = {
  enable = true;
  domain = "memo.example.org";
  openFirewall = true;

  proxy.server = "nginx";
  proxy.acme = {
    enable = true;
    email = "ops@example.org";
  };

  settings = {
    lang = "fr";
    auth = "oidc";
    oidcIssuer = "https://id.example.org";
    oidcClientId = "memo";
    adminGroup = "memo-admins";
  };

  environmentFile = "/run/secrets/memo.env";

  backup = {
    enable = true;
    keep = 30;
    schedule = "*-*-* 03:30:00";
  };
};

That creates the memo user, puts the database in /var/lib/memo, binds the server to loopback because nginx is on 443, gets the certificate, and fills in --origin from the domain. settings takes every option below by its flag name without the dashes. The repository's README describes the module's own options.

The options

Every option is a --flag and an environment variable both. A flag wins over the environment, which wins over the default. The two passwords and MEMO_SECRET are the exception: they are environment-only on purpose, because ps shows a unit's arguments to every user on the machine.

memo --help prints the whole list with the defaults, and memo --version prints the version. A flag memo does not know stops it there and then, before the database is opened, so a mistyped command leaves nothing behind.

FlagVariableDefaultWhat it does
--portPORT8088Port to listen on
--hostnameHOSTNAME0.0.0.0Address to bind, 127.0.0.1 under --auth=proxy
--langDEFAULT_LANGenLanguage for whoever has not chosen one: en, fr, es, ru
--baseurlBASEURL/Path prefix when served under a sub-path
--dbDB_PATHmemo.sqliteThe file holding every board
--tlsTLSoffServe HTTPS directly, for development
--tlsKeyTLS_KEYlocal-dev-key.pemPrivate key, with --tls
--tlsCertTLS_CERTlocal-dev-cert.pemCertificate, with --tls
--logoUrlLOGO_URLA logo, when none was sent from the Appearance tab
--faviconUrlFAVICON_URLReplaces the favicon
--backupDirBACKUP_DIRbackupsWhere snapshots are written and looked for
--backupKeepBACKUP_KEEP14How many snapshots to keep
--maxImagesMAX_IMAGES16How many pictures a board may hold

A flag is on by its presence, so --tls alone. As a variable it takes 1, true, yes or on; anything else, 0 included, is off.

DEFAULT_LANG is deliberately not spelled LANG. That one is already set to your own locale on nearly every machine, and inheriting it would change what visitors see because of how the server happened to be started.

Accounts

Memo always keeps accounts, and refuses to start without a backend: an install with no account system has no admin page and no way for anyone to find the boards they took part in. Opening a board and writing on it still needs no account. Making a new one is what asks for a sign-in.

One backend is live per process. Accounts are keyed on backend and subject, so changing --auth on an existing instance leaves the old accounts in place but unreachable, the admin one included.

FlagVariableDefaultWhat it does
--authAUTHlocallocal, proxy, ldap or oidc
--originORIGINThe public origin, https://memo.example
--adminADMIN_SUBJECTAccount to make admin, once, while no admin exists
--adminGroupADMIN_GROUPDirectory group whose members are admins
--closedRegistrationCLOSED_REGISTRATIONoffStop people creating local accounts themselves
--allowAnonymousBoardsALLOW_ANONYMOUS_BOARDSoffLet people make a board without signing in
--insecureCookiesINSECURE_COOKIESoffDrop Secure from the session cookie, plain-HTTP dev only
--allowPrivateFetchALLOW_PRIVATE_FETCHoffRead addresses inside this machine and its network, dev only

--adminGroup makes the directory authoritative: someone who loses the group upstream loses the role here at their next sign-in. Without it the role is whatever the admin page last set. --admin grants the role only while the instance has no admin at all, so leaving it in a unit file cannot quietly restore it after a revoke.

Passwords kept here (--auth=local)

Hashed with argon2id, at least ten characters. Sign-ups are open unless --closedRegistration says otherwise.

Behind a forward-auth proxy (--auth=proxy)

Authelia, and anything else that authenticates in front and passes the result in headers. Memo mints no session of its own here, so signing out upstream signs out here.

FlagVariableDefault
--trustedProxiesTRUSTED_PROXIES
--proxyUserHeaderPROXY_USER_HEADERremote-user
--proxyGroupsHeaderPROXY_GROUPS_HEADERremote-groups
--proxyNameHeaderPROXY_NAME_HEADERremote-name
--proxyLoginUrlPROXY_LOGIN_URL
--proxyLogoutUrlPROXY_LOGOUT_URL

--trustedProxies is mandatory and the server refuses to start without it. A header is only a header: anyone who can reach the port can send Remote-User: alice and be alice. It takes addresses or CIDR ranges, checked against the connection's peer address and never against anything in the request.

The proxy must overwrite those headers rather than append to them, or a client can add its own alongside. It must also cover /ws with the same forward-auth. Miss that and every page load is authenticated while every socket is anonymous, which shows up as an intermittent bug rather than as a security failure.

The way in and the way out both belong to the portal. --proxyLoginUrl and --proxyLogoutUrl say where they are; {back} in either is the address to come back to, percent-encoded, and {back64} is the same in base64. Without them Memo shows neither link, because a way out that signs nobody out is worse than no way out at all.

--proxyLoginUrl  'https://auth.example/?rd={back}'
--proxyLogoutUrl 'https://auth.example/logout'

Under YunoHost's SSOwat, which is a forward-auth proxy, the header names are not a preference, they are the security boundary. SSOwat clears every header a client sent whose name starts with ynh_ or ynh- before setting its own, and only those. Point --proxyUserHeader at anything else and a visitor can send that header themselves.

--auth proxy
--trustedProxies 127.0.0.1
--proxyUserHeader YNH_USER
--proxyNameHeader YNH_USER_FULLNAME
--proxyLoginUrl  'https://memo.example/yunohost/sso?r={back64}'
--proxyLogoutUrl 'https://memo.example/yunohost/portalapi/logout'
--origin https://memo.example

SSOwat sends no groups, so --adminGroup has nothing to match. Name the first admin with --admin instead, or grant the role from /admin.

A directory (--auth=ldap)

Memo searches with a service account, then binds as the person with the password they typed.

FlagVariableDefault
--ldapUrlLDAP_URL
--ldapBindDnLDAP_BIND_DN
LDAP_BIND_PASSWORD
--ldapBaseDnLDAP_BASE_DN
--ldapUserFilterLDAP_USER_FILTER(uid={{username}})
--ldapUidAttrLDAP_UID_ATTRentryUUID
--ldapGroupAttrLDAP_GROUP_ATTRmemberOf

The account is keyed on --ldapUidAttr, which has to be something that never changes. Not the DN: moving somebody between organisational units would orphan every board they own.

This is the only backend where Memo handles a directory password. If the directory already sits behind Authelia or Keycloak, --auth=proxy or --auth=oidc reaches the same accounts without that.

OpenID Connect (--auth=oidc)

FlagVariableDefault
--oidcIssuerOIDC_ISSUER
--oidcClientIdOIDC_CLIENT_ID
OIDC_CLIENT_SECRET
--oidcScopesOIDC_SCOPESopenid profile email groups

Register <origin>/auth/callback as the redirect URI. Authorization code with PKCE, and the claims come from the userinfo endpoint.

The admin page

An admin gets /admin: every board and every account, with a way to tick several boards and delete them together, and a Backups tab showing the copies of the database on the server, with a button to take one there and then, and a way to download or delete each. A snapshot holds everything the instance holds, private boards and password hashes included, so it is treated the way the server itself is.

Each tab is an address of its own: /admin/boards, /admin/users, /admin/backups and /admin/appearance. A tab is a link, so it can be bookmarked, opened in another window or sent to the person who has to look at it, and the browser's back button walks the tabs. /admin opens on the boards.

The look of an instance

The tab opens on the name, the line and the mark every page carries in its header. The name is what the header spells out and what the browser tab says; leave it empty and the instance is called Memo. The line is what the home page and the sign-in page carry beside the name; leave it empty and it stays the translated one, which follows whoever is reading. The logo is a picture you send, kept as one WebP and served to everybody at /logo.webp; send nothing and the instance shows the logo Memo came with. Three buttons decide what a header shows: the logo and the name, the logo alone, or the name alone.

--logoUrl still stands for an instance that never sent one from this page, alone in the header as it always was. A logo sent here wins over it.

Below that, the tab lists every colour and size the stylesheet declares, with the value it takes in light mode beside the value it takes in dark mode. Post-it colours, box colours, the border round a board, the ink of a mind map, the background of a page: each is a field, and each field shows its default until you type over it. Leave a field empty and that property stays as the stylesheet left it.

The page paints what you type as you type it, colour or name: the header of this very page takes the name, the logo and the way of showing them as you change them, so you see where it lands before saving. The switcher in the bottom left corner flips the page between light and dark, which is how to check the other column. Saving says so in a word beside the buttons, since most of what it saves shows up elsewhere.

Under the list there is a box for CSS of your own, added to every page after everything else. Nothing checks it, so a forgotten brace stops the rest of it working. Save a little at a time.

What you save is kept in the database and served to everybody here as /css/instance.css. Back to the defaults empties the lot: the name, the logo and your own CSS included.

Backups

VACUUM INTO takes a consistent snapshot while the server is running, so there is no downtime and no torn read. The Backups tab takes one, and so does the script in a checkout:

bun run backup                       # -> backups/memo-<timestamp>.sqlite
bun run backup --out /srv/backups --keep 14

It opens the copy and checks the expected tables are in it before pruning older ones, and exits non-zero if they are not. A systemd timer or a cron line is the usual way to run it nightly; on NixOS backup.enable does it for you.

Restoring is copying a snapshot over --db with the server stopped.

Pictures

A board carries a background and up to --maxImages pictures, sixteen by default, and an account carries one of its own. They live in the database rather than beside it, so one file is still the whole instance: one snapshot carries everything and a restore is a copy. Deleting a board takes its pictures with it.

Everything is recoded on the way in and only the result is kept: WebP, never wider than 1920, never enlarged, under 600 kB. /admin shows what pictures weigh per board and in total, which is the instrument rather than the limit.

Wiki accounts

A board can read a wiki as an account on it, and through a password on the whole site. Those passwords are kept in the database encrypted with AES-256-GCM under a key taken from MEMO_SECRET. Without that variable, Memo makes a random key the first time it needs one and keeps it beside the database, in <db>.secret, readable by its own user only.

A snapshot of the database holds the encrypted passwords and not the key. Keep the two apart, and keep the key: lose it and every board has to be given its wiki accounts again. Set MEMO_SECRET from a secrets file rather than on the command line.

The pictures of small cards from a wiki are copied into the database too, 320 pixels wide at most, shared by every board that shows them, and dropped once a day when no board shows them any more.

Worth knowing

  • Secrets are read from the environment only. Use EnvironmentFile in a systemd unit.
  • Names the server answers to itself, admin, login, logout, register, s, ws, api, css, images and vendor among others, cannot be board names. An existing board with such a name is left alone and logged at startup; rename it from the admin page.
  • Locking a board makes you its owner, and so does creating one while signed in. Share links come read-only or editable, are stored hashed, and are shown once when created. Revoking a grant or a link disconnects whoever was using it.
  • An API key made by an admin account carries the admin role. The developer guide has what a key reaches.

Documentation

Getting started Installation API

On this page