FUTO Notes server configuration (environment variables)
The FUTO Notes sync server is configured entirely through environment variables. It has no config file of its own and no settings UI. Most installs need only one variable, the sync password; everything else has a working default.
Where you set a variable depends on how you run the server, so start there.
Where settings go
Docker Compose (what the installer sets up). Put settings in the .env file next to docker-compose.yml. Docker Compose reads that file, not the server: Compose substitutes its values into the compose file, and only the variables the compose file names reach the container. The installer writes four:
FUTO_NOTES_PASSWORD="your sync password"FUTO_NOTES_DATA_DIR=/home/you/futo-notes/futo-notes-dataFUTO_NOTES_PORT=3005FUTO_NOTES_IMAGE=futotech/notes-server:stableAfter you edit .env, run docker compose up -d from the install directory. Compose recreates the container with the new values. A plain restart does not pick up changes.
Release binary or systemd. The server reads real environment variables, plus a .env file in its working directory if one exists. With the systemd unit from the server README, put them in /etc/futo-notes-server.env and restart the service.
Compose variables
These go in the Compose .env. They are names the compose file understands, not server variables.
| Variable | Default | What it does |
|---|---|---|
FUTO_NOTES_PASSWORD | none | The sync password. Passed to the server unchanged. |
FUTO_NOTES_PASSWORD_HASH | none | A scrypt hash of the sync password, used instead of FUTO_NOTES_PASSWORD. See store a hash. |
FUTO_NOTES_DATA_DIR | ./futo-notes-data | Host directory mounted at /data in the container. It holds db/notes.db and blobs/. Use an absolute path. |
FUTO_NOTES_PORT | 3005 | Host port published for the container’s port 3000. |
FUTO_NOTES_IMAGE | futotech/notes-server:stable | Server image. Pin a tag here for predictable upgrades. |
COOKIE_SECURE | true | Passed to the server. See the table below. |
BLOB_GC_ENABLED | true | Passed to the server. See the table below. |
The compose file fixes the rest: AUTH_MODE=password, PORT=3000, BLOB_DIR=/data/blobs, and the image’s DATABASE_URL=sqlite:/data/db/notes.db.
FUTO_NOTES_PORT accepts an address as well as a port. FUTO_NOTES_PORT=127.0.0.1:3005 publishes the server on the loopback interface only, which is what you want when a reverse proxy on the same machine handles outside traffic.
Variables the compose file does not pass through
ALLOW_FRESH_DATABASE, DEV_UI, and the Postgres pool settings are not in the compose file, so putting them in .env does nothing. To set one, create docker-compose.override.yml beside docker-compose.yml. Compose merges it automatically:
services: server: environment: ALLOW_FRESH_DATABASE: "true"If you installed by hand and run Compose with -f docker-compose.production.yml, the override is not loaded automatically. Add -f docker-compose.override.yml after it.
Server environment variables
This is everything the server itself reads.
| Variable | Default | What it does |
|---|---|---|
FUTO_NOTES_PASSWORD | none | Sync password in plain text. Required unless FUTO_NOTES_PASSWORD_HASH is set. |
FUTO_NOTES_PASSWORD_HASH | none | Scrypt hash of the sync password. Setting both password variables stops the server at startup. |
DATABASE_URL | sqlite:./data/notes.db | sqlite:<path>, postgres://... or postgresql://.... The Docker image sets sqlite:/data/db/notes.db. A relative SQLite path resolves against the working directory. |
PORT | 3005 | Port the server listens on, on all interfaces. The Docker image sets 3000. |
BLOB_DIR | ./blobs | Directory for encrypted blobs. The Docker image sets /data/blobs. |
AUTH_MODE | password | password or dev. Anything else stops the server. |
COOKIE_SECURE | true | Marks the session cookie Secure. Only the exact value false turns it off. The FUTO Notes apps sign in with a bearer token, so this affects browsers only. |
BLOB_GC_ENABLED | true | Only the exact value false turns off blob garbage collection. See server storage. |
ALLOW_FRESH_DATABASE | false | Only the exact value true overrides the fresh-database safety guard. See backup and restore. |
DEV_UI | false | Only the exact value true serves a developer test page at /dev. |
DB_POOL_MAX | 10 | Postgres only. Maximum open connections. |
DB_POOL_IDLE_TIMEOUT_MS | 10000 | Postgres only. Idle connection lifetime in milliseconds. |
Warning: AUTH_MODE=dev replaces the password with a passwordless login that accepts any email address. Anyone who can reach the server can sign in. It exists for development and tests only.
Warning: DEV_UI=true adds routes under /dev that require no sign-in: a page that shows the database path, buttons that run the cleanup jobs (including blob garbage collection) on demand, and an endpoint that deliberately crashes a request. Leave it off on any server other people can reach.
For TLS to a Postgres server, put sslmode and sslrootcert in DATABASE_URL. New installs use SQLite, and moving an older Postgres install to SQLite is optional.
Store a password hash instead of the password
FUTO_NOTES_PASSWORD_HASH lets the .env file hold a hash rather than the password itself. The format is:
scrypt:N=16384,r=8,p=1:<salt as hex>:<64-byte key as hex>The server always verifies with N=16384, r=8, p=1 and a 64-byte key, whatever the prefix says, so keep the prefix exactly as shown. There is no generator built into the server. This Python 3 snippet produces the right format; it needs a Python built against OpenSSL, which is the norm on Linux (the Python that ships with macOS lacks hashlib.scrypt):
python3 -c 'import getpass, hashlib, ospw = getpass.getpass("Sync password: ").encode()salt = os.urandom(16)key = hashlib.scrypt(pw, salt=salt, n=16384, r=8, p=1, dklen=64)print(f"scrypt:N=16384,r=8,p=1:{salt.hex()}:{key.hex()}")'Hash the same password your devices already use. Then, in .env, delete the FUTO_NOTES_PASSWORD line, add the hash, and run docker compose up -d:
FUTO_NOTES_PASSWORD_HASH=scrypt:N=16384,r=8,p=1:07dbbaa9...:1dcbf1b5...A hash is safer to keep on disk than the password, but it is not a secret you can publish: anyone holding it can try passwords against it offline. Keep .env at mode 600 either way. To change the password itself, see change the sync password.
The .env file format
When the server reads .env itself (binary installs), it follows these rules:
- One
KEY=valueper line. Blank lines and lines starting with#are skipped. A leadingexportis allowed. - A value wrapped in matching single or double quotes has the quotes removed. Nothing inside is unescaped, so
"a\"b"is read asa\"b. - In an unquoted value, a
#that follows a space or tab starts a comment. - Variables already set in the environment win over
.env. A missing.envis not an error.
Docker Compose parses its .env with its own rules, which do process escapes and $ substitution. The installer writes the password in double quotes, escapes \ and " with a backslash, and doubles every $. A file written for one parser is not always read the same way by the other, so if your password contains ", \ or $, write it for the one that will read it.
Fixed limits
These are protocol limits, not settings. There is no variable for any of them.
| Limit | Value |
|---|---|
| Login attempts | 10 per 60 seconds per client address. Over the limit, 429 with Retry-After. Behind a proxy, all clients share one address; see the rate limit behind a proxy. |
| One blob upload | 100 MiB (104,857,600 bytes) |
| One batch upload request | 32 MiB |
| Batch download request | 200 keys, 64 KiB request body |
| Session lifetime | 7 days from sign-in. Activity does not extend it. |
| Uploaded blob not yet attached to a note | Deleted after 24 hours |
| Replaced or deleted note versions | Kept 365 days |
| Retry records for app requests | 30 days (successful creates are kept for the life of the vault) |
| Cleanup jobs | First run 1 minute after start, then sessions every hour and storage maintenance every 6 hours |
Ignored variables from the old server
The current server ignores the variables below, which the earlier TypeScript server read, and logs one warning per variable at startup so you know:
level=WARN msg="MAX_BLOB_BYTES is ignored: the upload limit is fixed at 100 MiB"| Variable | Why it is ignored |
|---|---|
AUTH_RATE_LIMIT, AUTH_RATE_LIMIT_WINDOW_MS | Login rate limiting is fixed at 10 attempts per 60 seconds. |
BLOB_GC_INTERVAL_MS | Maintenance runs on the fixed built-in schedule. |
BLOB_RETENTION_DAYS | Retained blob lifetime is fixed at 365 days. |
DB_SSL, DB_SSL_INSECURE | Configure TLS with sslmode in DATABASE_URL. |
DB_SSL_CA | Configure TLS with sslrootcert in DATABASE_URL. |
LOG_LEVEL | The server uses its built-in log level. |
MAX_BATCH_BYTES | The batch limit is fixed at 32 MiB. |
MAX_BLOB_BYTES | The upload limit is fixed at 100 MiB. |
TRUST_PROXY | Forwarded client addresses are never trusted. Rate limiting uses the direct peer address. |
The warnings are safe to act on at your own pace: remove the variable and the warning goes away. The full source for all of this is in internal/config/config.go in the server repository.
Next steps
- Reverse proxy setup: put HTTPS in front of the server.
- Errors, health, and logs: what each startup error means.
- Back up, restore, and move a server: what
.envand the data directory hold.