βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β ββββββββββββββββββββββββ ββββ βββ βββββββ β
β βββββββββββββββββββββββββ βββββ ββββββββββββ β
β ββββββββ βββ βββ βββ ββββββ ββββββ βββ β
β ββββββββ βββ βββ βββ βββββββββββββ βββ β
β ββββββββ βββ ββββββββββββββ βββββββββββββββ β
β ββββββββ βββ βββββββ ββββββ βββββ βββββββ β
β β
β S E L F - H O S T E D S E R V E R β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Production-ready, one-server Standard Notes self-hosting for Ubuntu.
| Feature | Description |
|---|---|
| π³ Official Docker Flow | Uses the Standard Notes server, localstack, db, and cache topology |
| π Hardened by Default | HTTPS, HSTS, Fail2ban, UFW, secrets with openssl rand, services on 127.0.0.1 only |
| π¦ Automated Backups | Systemd timer with MySQL dumps, uploads, Redis snapshots, config β all SHA-256 verified |
| π©Ί Health Dashboard | Go-based status dashboard behind dual Basic Auth, accessible at /dashboard/ |
π οΈ snctl CLI |
Manage status, logs, backups, updates, registration, and PRO grants from one command |
| π Safe Reruns | Installer preserves existing secrets, backs up .env, and supports incremental reconfiguration |
| π Guided First Account | Automated wizard: open registration β wait for signup β grant PRO β lock registration |
| π‘οΈ Unattended Updates | Optional automatic Ubuntu security patching via unattended-upgrades |
graph TD
Internet((Internet)):::external
Internet -- "TCP 443 HTTPS" --> Nginx
subgraph Server["Ubuntu Server"]
Nginx["Nginx Reverse Proxy"]
Nginx -- "notes.example.com β :3000" --> SN["standardnotes/server"]
Nginx -- "files.example.com β :3125" --> SN
Nginx -- "/dashboard/ β :8090" --> Dash["Go Dashboard"]
subgraph Docker["Docker Compose Network"]
SN
MySQL["MySQL 8"]
Redis["Redis 6 Alpine"]
LS["LocalStack 3.0"]
end
SN --- MySQL
SN --- Redis
SN --- LS
end
classDef external fill:#f9f,stroke:#333,color:#000
Note Publicly open ports: 80/tcp (Let's Encrypt + redirect), 443/tcp (HTTPS), and your SSH port. Do not expose
3000,3125,8090, MySQL, Redis, or LocalStack publicly.
# 1 Β· Upload or clone the repo on your Ubuntu server
git clone https://github.com/cempack/standard-notes-project.git
# 2 Β· Run the installer
cd standard-notes-project
chmod +x install.sh
sudo ./install.sh
# 3 Β· Follow the interactive prompts (domains, certs, dashboard, etc.)
# 4 Β· Create your first account using the guided wizard
snctl first-account you@example.comTip The installer is safe to rerun. It preserves existing Standard Notes secrets from
.env, backs up the current.envbefore rewriting managed values, and keeps the dashboard password if you leave it blank without changing the username.
- Requirements
- Installation
- What the Installer Does
- Management CLI β snctl
- Client Setup
- Dashboard
- Health Checks & Tests
- Backups & Restore
- Updates
- Security
- Troubleshooting
- Configuration Reference
- Logs
| Requirement | Details |
|---|---|
| OS | Fresh Ubuntu 22.04 LTS or 24.04 LTS |
| Access | Root or sudo |
| DNS | notes.example.com β server IP, files.example.com β server IP |
| Firewall | Cloud security group allows 80/tcp, 443/tcp, and your SSH port |
| RAM | β₯ 2 GB recommended by Standard Notes for the Docker setup |
Upload or unzip this repository on the server, then run:
cd standard-notes-project
chmod +x install.sh
sudo ./install.shThe interactive installer asks for:
| Prompt | Example / Default |
|---|---|
| Install directory | /opt/standardnotes |
| Notes/API domain | notes.example.com |
| Files domain | files.example.com |
| Let's Encrypt email | admin@example.com |
| Dashboard username & password | (your choice) |
| SSH port to keep open in UFW | 22 |
| Use Let's Encrypt or self-signed certs? | Let's Encrypt |
| Enable unattended Ubuntu security updates? | Yes/No |
| Enable UFW firewall rules? | Yes/No |
| Disable new user registration immediately? | No (until first account exists) |
Install snctl management CLI? |
Yes |
| Run guided first-account flow? | Yes/No |
| Add sudo user to Docker group? | (optional, root-equivalent) |
| Backup retention & schedule | (configurable) |
| Run an initial backup? | Yes/No |
Click to expand full details
- Docker Engine & Docker Compose plugin
- Nginx
- Certbot
- Fail2ban
- UFW
unattended-upgrades- Go compiler (for the dashboard)
- Backup / test dependencies
Copies the project to /opt/standardnotes (or your selected directory) and generates .env with:
| Variable | Source |
|---|---|
DB_PASSWORD |
openssl rand |
AUTH_JWT_SECRET |
openssl rand |
AUTH_SERVER_ENCRYPTION_SERVER_KEY |
openssl rand |
VALET_TOKEN_SECRET |
openssl rand |
AUTH_SERVER_DISABLE_USER_REGISTRATION |
false by default |
PUBLIC_FILES_SERVER_URL |
https://files.example.com |
docker compose pull && docker compose up -d| Route | Upstream |
|---|---|
https://notes.example.com |
127.0.0.1:3000 |
https://files.example.com |
127.0.0.1:3125 |
https://notes.example.com/dashboard/ |
127.0.0.1:8090 |
- Fail2ban jails for Nginx auth/bot/4xx abuse
- Automatic Ubuntu security updates (if selected)
standardnotes-backup.timersystemd backup schedule- Go dashboard built and run as locked-down
sn-dashboardsystem user snctlinstalled to/usr/local/bin/snctl(when selected)
When selected during installation, the installer creates:
/usr/local/bin/snctl β /opt/standardnotes/scripts/snctl
| Command | Description |
|---|---|
snctl status |
Show Docker Compose service status |
snctl health |
Run the full health checker |
snctl logs [service] |
Follow Docker logs (default: server) |
snctl backup |
Run a manual backup |
snctl restore ARCHIVE |
Restore from a backup archive |
snctl update |
Backup β pull β restart β health check |
snctl first-account EMAIL |
Guided: register β grant PRO β lock |
snctl grant-pro EMAIL [--wait] |
Grant server-side PRO_USER / PRO_PLAN |
snctl lock-registration |
Disable new user sign-ups |
snctl unlock-registration |
Re-enable sign-ups |
snctl registration-status |
Print current registration setting |
snctl dashboard |
Print the dashboard URL |
snctl first-account EMAIL@ADDR runs an automated flow that:
- Opens registration
- Prints the Sync Server URL
- Waits up to 30 minutes for that email to register
- Grants server-side
PRO_USER/PRO_PLAN - Asks whether to lock registration afterward
In the Standard Notes desktop or mobile app:
- Open the account menu
- Choose Advanced options
- Under Sync Server, choose Custom
- Enter your notes URL:
https://notes.example.com - Register your first account on that custom server
- Create a note and confirm it syncs
After your account exists, run the guided CLI flow (if you didn't during install):
snctl first-account you@example.comThis grants server-side PRO_PLAN and asks whether to lock registration.
To lock registration manually:
snctl lock-registrationYou can also rerun sudo /opt/standardnotes/install.sh and answer yes to disabling registration.
Important This unlocks server-side premium features only. It does not unlock client-side premium features such as Super notes or Nested tags in official clients. For full client-side premium features, use a Standard Notes offline plan.
File uploads are served through the files domain set in .env:
PUBLIC_FILES_SERVER_URL=https://files.example.com
Note The official hosted web app at
app.standardnotes.comis not used with a custom sync server. Use the desktop/mobile apps or self-host the Standard Notes web app separately.
Server-side PRO subscription β manual method
You can also grant PRO via the helper script directly:
sudo /opt/standardnotes/scripts/grant-pro-subscription.sh --wait EMAIL@ADDRManual equivalent from the official docs:
docker compose exec db sh -c "MYSQL_PWD=\$MYSQL_ROOT_PASSWORD mysql \$MYSQL_DATABASE -e \
'INSERT INTO user_roles (role_uuid , user_uuid) VALUES ((SELECT uuid FROM roles WHERE name=\"PRO_USER\" ORDER BY version DESC limit 1) ,(SELECT uuid FROM users WHERE email=\"EMAIL@ADDR\")) ON DUPLICATE KEY UPDATE role_uuid = VALUES(role_uuid);' \
"
docker compose exec db sh -c "MYSQL_PWD=\$MYSQL_ROOT_PASSWORD mysql \$MYSQL_DATABASE -e \
'INSERT INTO user_subscriptions SET uuid=UUID(), plan_name=\"PRO_PLAN\", ends_at=8640000000000000, created_at=0, updated_at=0, user_uuid=(SELECT uuid FROM users WHERE email=\"EMAIL@ADDR\"), subscription_id=1, subscription_type=\"regular\";' \
"The dashboard is available at:
https://notes.example.com/dashboard/
| Layer | Mechanism | Config File |
|---|---|---|
| 1. Nginx | Basic Auth | /etc/nginx/standardnotes-dashboard.htpasswd |
| 2. App | Salted SHA-256 Basic Auth | /etc/standardnotes-dashboard.env |
- β Local API status
- β Local files server status
- β Public HTTPS status for notes and files domains
- β Latest backup metadata
- β Recent Nginx and Standard Notes log snippets (when readable)
sudo systemctl status standardnotes-dashboard
sudo systemctl restart standardnotes-dashboard
sudo journalctl -u standardnotes-dashboard -n 100 --no-pager# Run the built-in health checker
sudo /opt/standardnotes/scripts/healthcheck.sh
# Run the guided test script
sudo /opt/standardnotes/scripts/test.shManual checks
# HTTPS endpoints
curl -I https://notes.example.com
curl -I https://files.example.com
# Internal endpoints
curl -sS -o /dev/null -w 'API HTTP %{http_code}\n' http://127.0.0.1:3000
curl -sS -o /dev/null -w 'Files HTTP %{http_code}\n' http://127.0.0.1:3125
# Docker status
cd /opt/standardnotes && docker compose ps
cd /opt/standardnotes && docker compose logs -f serverNote A
404or401from a root API URL can still mean the service is reachable; the important first signal is that the connection succeeds and does not return a5xxor timeout.
Backups are managed by the systemd timer:
# Check the timer
systemctl list-timers standardnotes-backup.timer --no-pager
# Run a manual backup
sudo /opt/standardnotes/scripts/backup.shBackups are written to:
/opt/standardnotes/backups/standardnotes-backup-YYYYmmddTHHMMSSZ.tar.gz
/opt/standardnotes/backups/standardnotes-backup-YYYYmmddTHHMMSSZ.tar.gz.sha256
/opt/standardnotes/backups/LATEST.json
Each backup includes:
| Component | Content |
|---|---|
| Database | MySQL dump |
| Files | Upload data |
| Cache | Redis data snapshot |
| Config | .env, docker-compose.yml, localstack_bootstrap.sh, .install-config (when present) |
Warning Backups contain secrets. Store off-server copies securely and restrict access.
Copy the backup archive and its .sha256 sidecar to the server, then run:
sudo /opt/standardnotes/scripts/restore.sh /path/to/standardnotes-backup-YYYYmmddTHHMMSSZ.tar.gzYou must type RESTORE to confirm. The restore script:
- Verifies the
.sha256sidecar when present - Saves a pre-restore copy of current config to
/opt/standardnotes/pre-restore-* - Stops the current Compose stack
- Restores config, uploads, and Redis data
- Starts MySQL / Redis / LocalStack
- Moves the current
data/mysqldirectory aside todata/mysql.pre-restore-*so the restored.envdatabase password can initialize a clean MySQL data directory - Drops and recreates the Standard Notes database in the clean MySQL instance
- Imports the SQL dump
- Starts the full stack
After restore, verify with:
sudo /opt/standardnotes/scripts/healthcheck.shUse the update helper:
sudo /opt/standardnotes/scripts/update.shIt runs a backup first, then pulls and restarts:
cd /opt/standardnotes
docker compose pull
docker compose up -dManual update steps
sudo /opt/standardnotes/scripts/backup.sh
cd /opt/standardnotes
docker compose pull
docker compose up -d
sudo /opt/standardnotes/scripts/healthcheck.shTip For major Standard Notes server changes, compare this repo's
.env.example,docker-compose.yml, andlocalstack_bootstrap.shwith the upstream Standard Notes examples before updating.
| Measure | Details |
|---|---|
| Secret generation | openssl rand, stored in .env with mode 0600 |
| Docker group | Not added unless you opt in β membership is root-equivalent |
| Service binding | Docker ports bound to 127.0.0.1 only |
| Public entry point | Nginx is the only public HTTP gateway |
| Transport | HTTPS redirects and HSTS enabled |
| Dashboard | Basic Auth protected, served only through HTTPS |
| Intrusion protection | Fail2ban protects SSH and Nginx auth/bot abuse |
| Firewall | UFW opens only SSH, 80, and 443 (when enabled) |
| OS patching | Ubuntu unattended security updates (when selected) |
| Container restart | restart: unless-stopped policy |
| Backup files | Mode 0600, contain sensitive data β handle accordingly |
π΄ Docker Hub rate limit ("You have reached your unauthenticated pull rate limit")
Docker Hub limits the number of image pulls for unauthenticated users. The installer now automatically retries with backoff and offers to run docker login interactively.
If you still hit the limit:
- Create a free Docker Hub account at hub.docker.com/signup
- Log in on the server:
docker login- Rerun the installer:
sudo ./install.shTip Authenticated (free) accounts get 200 pulls per 6 hours instead of 100. Paid accounts have higher limits.
π΄ Certbot fails
Check all of these:
- DNS A/AAAA records point to this server
- Cloud firewall allows
80/tcpinbound - UFW allows
80/tcp - No other service is using port 80
http://notes.example.com/.well-known/acme-challenge/testreaches this server
Then rerun:
cd /opt/standardnotes
sudo ./install.shTip For testing without rate limits, choose Let's Encrypt staging certificates. Staging certificates are not trusted by clients.
π΄ Nginx returns 502
The Docker service behind Nginx is not reachable yet.
cd /opt/standardnotes
docker compose ps
docker compose logs --tail=200 server
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3125Note Wait a few minutes on first boot β MySQL initialization can take time.
π΄ Docker Compose fails with DB password / MySQL auth error
If this is a rerun on an existing install, do not regenerate DB_PASSWORD. The installer preserves it automatically.
If you manually changed it, restore the old value from .env.bak.* or rotate the MySQL password properly inside MySQL.
π΄ Dashboard login loops
The dashboard uses both Nginx Basic Auth and app Basic Auth with the same credentials. Rerun the installer and set a new dashboard password to resync both layers:
cd /opt/standardnotes
sudo ./install.shπ΄ Fail2ban banned my IP
sudo fail2ban-client status
sudo fail2ban-client status standardnotes-nginx-dashboard-auth
sudo fail2ban-client unban YOUR_IP_ADDRESSπ΄ Client cannot sync
Check:
- The client Sync Server is exactly
https://notes.example.comwith no trailing path - Your certificate is trusted β not self-signed or Let's Encrypt staging
curl -I https://notes.example.comsucceedsdocker compose psshows the server container runningPUBLIC_FILES_SERVER_URL=https://files.example.comis present in.env
π Changing domains later
- Update DNS for the new domains
- Rerun the installer:
cd /opt/standardnotes sudo ./install.sh - Enter the new notes/files domains
- Let the installer update
.env, Nginx, certificates, dashboard config, and health checks - Confirm
.envcontains the new files URL:grep '^PUBLIC_FILES_SERVER_URL=' /opt/standardnotes/.env
π Changing the dashboard password
Rerun the installer and enter a new dashboard password when prompted:
cd /opt/standardnotes
sudo ./install.shOr manually update Nginx Basic Auth and the dashboard app hash. Rerunning the installer is safer because it keeps both layers in sync.
ποΈ Changing Standard Notes secrets or database password
Warning Do not rotate
DB_PASSWORDby editing.envalone on an existing database. MySQL users inside the existing data directory will still have the old password.
Safer approaches:
- New empty install: stop containers, remove
data/mysql, update.env, and start again. - Existing install: take a backup, update the MySQL user's password inside MySQL, update
.env, then restart. Test thoroughly.
The installer intentionally preserves existing .env secrets on rerun.
π Changing host ports
The project defaults are intentionally private:
127.0.0.1:3000:3000
127.0.0.1:3125:3104If you must change them:
- Edit
/opt/standardnotes/docker-compose.ymlport bindings - Edit Nginx upstreams in
/etc/nginx/sites-available/standardnotes.confor update the template and rerun the installer - Restart:
cd /opt/standardnotes docker compose up -d sudo nginx -t && sudo systemctl reload nginx
Caution Keep the services bound to
127.0.0.1unless you have a very specific reason not to.
| What | Command |
|---|---|
| Standard Notes server | docker compose logs -f server |
| MySQL | docker compose logs -f db |
| LocalStack | docker compose logs -f localstack |
| Nginx errors (API) | sudo tail -f /var/log/nginx/standardnotes-error.log |
| Nginx errors (files) | sudo tail -f /var/log/nginx/standardnotes-files-error.log |
| Dashboard | sudo journalctl -u standardnotes-dashboard -f |
| Backups | sudo journalctl -u standardnotes-backup -n 100 --no-pager |
Note Standard Notes container file logs are also mounted at
/opt/standardnotes/logs/. Alldocker composecommands should be run from/opt/standardnotes.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β S E L F - H O S T E D S E R V E R β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Made with π security in mind
π Repository Tree
standard-notes-project/
install.sh
README.md
docker-compose.yml
.env.example
.gitignore
localstack_bootstrap.sh
backups/
.gitkeep
configs/
logrotate/
standardnotes
unattended-upgrades/
52standardnotes-unattended-upgrades
dashboard/
go.mod
main.go
fail2ban/
filter.d/
standardnotes-nginx-4xx.conf
standardnotes-nginx-dashboard-auth.conf
jail.d/
standardnotes-nginx.local
nginx/
standardnotes-http.conf.template
standardnotes-https.conf.template
scripts/
backup.sh
grant-pro-subscription.sh
healthcheck.sh
restore.sh
snctl
test.sh
update.sh
systemd/
standardnotes-backup.service.template
standardnotes-backup.timer.template
standardnotes-dashboard.service.template