A self-hosted, privacy-focused note-taking web application built with Flask. Designed for personal use on Raspberry Pi, home servers, or any Docker-capable host.
| Component | Technology |
|---|---|
| Backend | Python 3.13, Flask, Gunicorn |
| Database | SQLite (WAL mode) |
| Frontend | Bootstrap 5, CKEditor 5, Font Awesome, Prism.js |
| Container | Alpine Linux, non-root user |
| Security | CSRF protection, CSP, XSS sanitization, rate limiting, scrypt password hashing |
docker run -d \
--name rpinotes \
-p 9000:9000 \
-v rpinotes_data:/data \
certyiknofetch/rpinotes:latest
Open http://localhost:9000 in your browser.
services:
rpinotes:
image: certyiknofetch/rpinotes:5.0
container_name: rpinotes
ports:
- "9000:9000"
restart: always
volumes:
- rpinotes_data:/data
volumes:
rpinotes_data:
services:
rpinotes:
image: certyiknofetch/rpinotes:latest
container_name: rpinotes
restart: unless-stopped
ports:
- "9000:9000"
command: >-
sh -c "gunicorn -b 0.0.0.0:9000 app:app
-w ${GUNICORN_WORKERS:-2}
--threads ${GUNICORN_THREADS:-4}
--timeout ${GUNICORN_TIMEOUT:-60}
--worker-class gthread
--keep-alive ${GUNICORN_KEEPALIVE:-5}
--log-level ${GUNICORN_LOGLEVEL:-info}
--access-logfile ${GUNICORN_ACCESSLOG:--}
--error-logfile ${GUNICORN_ERRORLOG:--}"
environment:
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- ALLOWED_ORIGIN=${ALLOWED_ORIGIN:-https://notes.example.com}
- PROXY_TRUST=${PROXY_TRUST:-1}
- UPLOAD_DIR=${UPLOAD_DIR:-/data/uploads}
- DB_PATH=${DB_PATH:-/data/db/posts.db}
volumes:
- ./data/uploads:/data/uploads
- ./data/db:/data/db
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://localhost:9000/health').getcode()==200 else sys.exit(1)"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
| Variable | Default | Description |
|---|---|---|
SECRET_KEY | Random (regenerated on startup) | Flask session secret. Set a fixed value in production. If missing and you use multiple workers, CSRF/session validation can fail across workers. |
DATABASE_PATH | /data/posts.db | Path to the SQLite database file. |
DB_PATH | /data/posts.db | Alias for DATABASE_PATH (either works). |
UPLOAD_FOLDER | /data/uploads | Directory for file attachments. |
UPLOAD_DIR | /data/uploads | Alias for UPLOAD_FOLDER (either works). |
PORT | 9000 | Port for the built-in dev server (ignored when using Gunicorn). |
ALLOWED_ORIGIN | (empty) | Set to your public app URL (for example https://notes.example.com) when behind a reverse proxy. |
PROXY_TRUST | 0 | Set to 1 when behind a reverse proxy (Nginx Proxy Manager, Traefik, etc.) so Flask trusts forwarded headers via ProxyFix. |
SECURE_COOKIES | 0 | Set to 1 only if cookie Secure behavior is validated end-to-end in your proxy/CDN setup. |
FLASK_DEBUG | 0 | Set to 1 to enable debug mode (dev server only, never in production). |
.env file)| Variable | Default | Description |
|---|---|---|
GUNICORN_WORKERS | 2 | Number of worker processes. Rule of thumb: (2 × CPU cores) + 1. |
GUNICORN_THREADS | 4 | Threads per worker (gthread worker class). |
GUNICORN_TIMEOUT | 60 | Worker timeout in seconds. |
GUNICORN_KEEPALIVE | 5 | Keep-alive timeout for connections. |
GUNICORN_LOGLEVEL | info | Log level: debug, info, warning, error, critical. |
GUNICORN_ACCESSLOG | - (stdout) | Access log file path. - for stdout. |
GUNICORN_ERRORLOG | - (stderr) | Error log file path. - for stderr. |
All persistent data lives under /data inside the container:
/data/
├── posts.db # SQLite database (notes, tags, passwords)
└── uploads/
└── <uuid>/ # Per-note attachment folders
├── image.png
└── doc.pdf
Mount /data as a Docker volume or bind mount to persist across container restarts.
If using DB_PATH=/data/db/posts.db, the directory structure becomes:
/data/
├── db/
│ └── posts.db
└── uploads/
└── <uuid>/
| Feature | Detail |
|---|---|
| CSRF | Flask-WTF CSRF tokens on all forms |
| XSS | Server-side HTML sanitization (bleach), CKEditor whitelist, escapeHtml() in JS |
| Content-Security-Policy | Strict CSP — default-src 'self', whitelisted media embed domains |
| Password Hashing | scrypt via Werkzeug |
| Session | HttpOnly, SameSite=Lax, 30-minute expiry, optional Secure flag |
| Rate Limiting | Unlock attempts: 5/min, Write operations: 20/min (per IP) |
| File Uploads | Extension whitelist, MIME-type validation, magic-byte checking, 100MB limit |
| DoS Protection | Max 500 notes, 1GB total storage cap |
| Headers | X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, HSTS |
| Note IDs | UUIDs (not sequential integers) — prevents enumeration |
| Non-root Container | Runs as appuser inside Docker |
| Method | Path | Description |
|---|---|---|
GET | / | Main page (notes list) |
POST | /create | Create a new note |
GET/POST | /edit/<uid> | View/edit a note |
POST | /delete/<uid> | Delete a note |
GET/POST | /unlock/<uid> | Unlock a password-protected note |
POST | /lock/<uid> | Set a password on a note |
POST | /relock/<uid> | Re-lock an unlocked note (clears session) |
POST | /remove_lock/<uid> | Remove password from a note |
POST | /toggle_pin/<uid> | Pin/unpin a note |
POST | /update_color/<uid> | Change note color |
POST | /upload | Upload file attachment |
GET | /uploads/<uid>/<filename> | Serve an attachment |
GET | /toggle-theme | Switch dark/light theme |
GET | /api/posts | JSON API — paginated notes list |
GET | /health | Health check (returns {"status": "ok"}) |
Multi-architecture Docker images are published for:
linux/amd64 (x86_64 — desktops, servers, VMs)linux/arm64 (aarch64 — Raspberry Pi 4/5, Apple Silicon, ARM servers)| Tag | Description |
|---|---|
certyiknofetch/rpinotes:latest | Latest stable release |
certyiknofetch/rpinotes:5.0 | Version 5.0 |
cd app
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Run with Flask dev server
export DATABASE_PATH=./posts.db
export UPLOAD_FOLDER=./uploads
python3 app.py
The app will be available at http://localhost:9000.
Private / self-hosted use.
Content type
Image
Digest
sha256:02e2bd4bf…
Size
32.6 MB
Last updated
3 months ago
docker pull certyiknofetch/rpinotes