A simple, modern TODO Proof of Concept built with a TypeScript stack.
- Server: Express.js + SQLite (Node.js built-in
node:sqlite) + TypeScript - Client: React + TypeScript + Vite + Bootstrap (React‑Bootstrap), with a Markdown editor
- Deploy: docker‑compose
- 🔐 Auth – sign up / sign in with JWT.
- 🛡️ Admin panel – list & remove users. Admin credentials come from
.env. - ✅ Tasks – create, edit, complete, delete.
- 🗂️ Lists – organize tasks into TODO lists.
- 🤝 Sharing – share a list with another user by email, as read-only (only the owner/editors can change tasks) or can edit. Shared lists appear in the sidebar with a distinct icon (👥) and a read-only badge; owners get a 🔗 "shared" indicator. Manage members (change permission / remove) from the list's ⋯ menu, where owners can also delete and members can leave.
- 🏷️ Tags – autocomplete from existing tags or type a new one to create it on the fly.
- 🔁 Repeats – none / daily / weekly (pick weekdays) / monthly (pick days) / custom (every N days/weeks/months).
- 📅 Dates – assign a specific date to a task.
- ⭐ Today view – shows tasks due today plus recurring tasks that occur today.
- 📝 Markdown – task descriptions are written in a Markdown editor and rendered as HTML.
- 🌗 Dark / light theme – toggle in the navbar; preference is remembered.
- 🌍 Localization (i18n) – UI available in English, Russian, Ukrainian and Serbian, with automatic browser language detection.
✈️ Telegram bot – link your account from Settings → Telegram, then review your lists, browse tasks, add tasks and mark them complete straight from Telegram. You can also Log in with Telegram on the sign-in page, and admins see a✈️ icon next to users who linked Telegram.- 📱 Responsive, modern UI – themed confirmation dialogs, toast notifications, optimistic task toggles with rollback on error.
- ⌨️ Keyboard shortcuts –
Nnew task,/focus search,Ttoday view,Aall tasks. - 🗣️ Natural-language quick add – type
Buy milk #groceries tomorrowto set title, tags and due date in one go. - 📊 Task sorting – sort by due date, title, or completion status; overdue tasks are highlighted.
- 📲 Installable PWA – installable on desktop & mobile (Chrome/Edge/Safari), runs in a standalone window, works offline via a service worker, with an auto-updating cache.
- 💾 Automated backups – a Docker sidecar takes a consistent SQLite snapshot on deploy and once a day, gzips it to a host folder, prunes old ones by retention, and ships a one-command restore. See Data persistence & backups.
mtodo/
├── server/ # Express + SQLite API (TypeScript)
│ └── src/telegram/ # Telegram bot integration (Telegraf)
├── client/ # React + Vite SPA (TypeScript)
├── docker/ # Docker configs (compose, Dockerfiles, nginx, ngrok)
│ └── backup/ # SQLite backup/restore + daily scheduler scripts
├── .env.example
└── requirements.md
mTodo ships with two installation variants: a production variant that runs the whole stack in Docker, and a development variant that runs the API and client locally with hot reload.
- Production: Docker + Docker Compose.
- Development: Node.js 24+ and npm (required for the built-in
node:sqlitemodule).
In both cases, start by creating your environment file:
cp .env.example .env # adjust secrets / admin credentialsAll Docker configs live in docker/. The compose file builds from
the repository root, loads variables from the repo-root .env, and runs the
API and client with restart policies and a persistent SQLite database
bind-mounted from a host folder (DATA_HOST_DIR, default ./data, mounted at
/data inside the server container).
npm run docker:up
# or, equivalently, from the repo root:
docker compose -f docker/docker-compose.yml up --build- Client (static build served by nginx): http://localhost:5173
- API: http://localhost:4000/api
- Sign in as admin using
ADMIN_EMAIL/ADMIN_PASSWORDfrom.env.
To run it in the background, add -d; stop it with npm run docker:down
(append -v to the compose down command to also drop the database volume).
The stack includes an optional ngrok service that exposes the client to the
public internet. Set NGROK_AUTHTOKEN in .env (from the
ngrok dashboard) and bring the stack up — the
public URL is shown in the ngrok inspector at http://localhost:4040.
Install all dependencies (server + client) from the repo root:
npm run install:all
# (equivalent to running `npm install` in both ./server and ./client)Then run the API and client in two terminals:
# 1) API – ts-node-dev with auto-respawn
npm run dev:server # http://localhost:4000
# 2) Client – Vite dev server with HMR
npm run dev:client # http://localhost:5173The client reads the API base URL from VITE_API_URL (defaults to
http://localhost:4000/api).
npm run build # builds both server (tsc) and client (vite build)
# Serve the compiled API and the built client separately:
npm --prefix server start # node dist/index.js
npm --prefix client run preview # static preview on http://localhost:5173mTodo is hardened for production but stays zero-config for local / LAN use. Pick the scenario that matches your setup.
This is the default and needs no extra configuration. It works on
http://localhost as well as on a LAN IP such as http://192.168.1.10.
ALLOWED_ORIGINSis left empty, so the API reflects any origin (CORS is open).- HSTS is disabled, so plain HTTP is not force-upgraded to HTTPS.
NODE_ENVstaysdevelopment, so the defaultJWT_SECRETis accepted.
cp .env.example .env
npm run docker:up
# Client: http://localhost:5173 API: http://localhost:4000/apiTo reach it from other devices on your network, point the client at the host's LAN IP when building so the browser calls the right API address:
# In .env (consumed at client build time):
VITE_API_URL=http://192.168.1.10:4000/apiThen open http://192.168.1.10:5173 from any device on the LAN.
Note: PWA install/offline requires a secure context (HTTPS or
localhost). Over a raw LAN IP the app still runs, but it won't be installable until served over HTTPS.
For an internet-facing deployment, switch on the production safeguards. At a minimum set:
NODE_ENV=production # enables the JWT-secret guard
JWT_SECRET=<long-random-string> # REQUIRED — server won't start with the default
ALLOWED_ORIGINS=https://todo.example.com # comma-separated CORS allow-list
TRUST_PROXY=true # when behind nginx/traefik/Caddy
VITE_API_URL=https://todo.example.com/api # API base URL the client calls
ADMIN_EMAIL=you@example.com # change the default admin
ADMIN_PASSWORD=<strong-password>Generate a strong secret with:
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"Key behaviors in production:
- The server refuses to start if
NODE_ENV=productionandJWT_SECRETis still the default dev value. - CORS only allows the origins listed in
ALLOWED_ORIGINS(requests from other origins are rejected). - Rate limiting applies per client IP — set
TRUST_PROXY=trueso the real client IP is read from the proxy'sX-Forwarded-*headers instead of the proxy's own IP.
Terminate TLS at the proxy and forward /api to the server and everything else
to the client container. Forwarded headers must be passed through for rate
limiting to work (TRUST_PROXY=true):
server {
listen 443 ssl;
server_name todo.example.com;
# ...ssl_certificate / ssl_certificate_key...
location /api/ {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:5173; # nginx-served static client
}
}With this layout the client and API share one origin, so you can set
ALLOWED_ORIGINS=https://todo.example.com and VITE_API_URL=https://todo.example.com/api.
The Docker stack bundles an optional ngrok service to expose the client
publicly without a server. Set NGROK_AUTHTOKEN in .env, bring the stack up,
and read the public URL from the ngrok inspector at http://localhost:4040.
Because ngrok terminates TLS, set TRUST_PROXY=true so rate limiting sees the
real client IP, and add the ngrok URL to ALLOWED_ORIGINS if you lock CORS down.
Defaults are generous and shouldn't affect normal use. Adjust per IP via:
| Variable | Default | Meaning |
|---|---|---|
RATE_LIMIT_WINDOW_MS |
900000 |
Window length in ms (15 min) |
RATE_LIMIT_MAX |
600 |
Max API requests per window |
RATE_LIMIT_AUTH_MAX |
30 |
Max sign-in/sign-up attempts per window |
The SQLite database lives at DATABASE_FILE (default /data/mtodo.sqlite in
Docker). /data is bind-mounted from a real host folder — ./data at the
repo root by default, override with DATA_HOST_DIR. Because it's an ordinary
folder on the host (not a Docker named volume), the database survives image
rebuilds, docker compose down -v, and docker volume prune. The only way to
lose it is to delete the folder itself.
The Compose stack includes a backup sidecar (docker/backup.Dockerfile) that
runs alongside the server. It:
- takes a consistent snapshot on startup (so a backup exists right after
deploy), and then once a day at
BACKUP_TIME(HH:MM, UTC, default03:00); - uses SQLite's online
.backupAPI, so snapshots are safe to take while the server is writing (the DB runs in WAL mode); - gzips each snapshot and copies it to a host folder —
./backupsby default (override withBACKUP_HOST_DIR) — so backups survive even if the Docker volume is removed; - verifies each snapshot with
PRAGMA integrity_checkand prunes backups older thanBACKUP_RETENTION_DAYS(default7).
Backups are named mtodo-YYYYMMDD-HHMMSS.sqlite.gz. Relevant env vars
(BACKUP_TIME, BACKUP_RETENTION_DAYS, BACKUP_HOST_DIR) are documented in
.env.example.
Take an on-demand backup of the running stack:
npm run db:backup
# or: docker compose -p mtodo -f docker/docker-compose.yml exec backup sh /scripts/backup.shThe restore.sh script decompresses a chosen backup, verifies its integrity,
keeps a safety copy of the current database (*.pre-restore.*), and replaces the
live file. Stop the server first so it isn't writing during the swap:
# List available backups
npm run db:backups
# Restore the most recent backup
docker compose -p mtodo -f docker/docker-compose.yml stop server
npm run db:restore # newest backup
# or restore a specific one:
# docker compose -p mtodo -f docker/docker-compose.yml run --rm backup \
# sh /scripts/restore.sh mtodo-20260101-030000.sqlite.gz
docker compose -p mtodo -f docker/docker-compose.yml start serverThe backup/restore scripts are plain POSIX shell (docker/backup/) and read
DATABASE_FILE / BACKUP_DIR from the environment, so they also work outside
Docker (they only require the sqlite3 CLI).
The client is a PWA powered by vite-plugin-pwa:
- A web app manifest (
manifest.webmanifest) and icon set are generated fromclient/public/logo.svgat build time. - A Workbox service worker precaches the app shell and uses a
network-first strategy for
/apiGET requests, so previously loaded data is available offline. New versions auto-update (registerType: 'autoUpdate').
Install it:
- Build & serve over
http://localhostor HTTPS (service workers require a secure context;localhostcounts). The Docker setup serves it onhttp://localhost:5173. - In Chrome/Edge, click the install icon in the address bar (or ⋮ → Install mTodo). On iOS Safari use Share → Add to Home Screen.
During
vite dev, the service worker is enabled (devOptions.enabled), so you can test install/offline behavior locally too.
See .env.example:
| Variable | Description |
|---|---|
PORT |
API server port |
NODE_ENV |
development or production |
JWT_SECRET |
Secret used to sign auth tokens (required in production; server refuses to start with the default) |
DATABASE_FILE |
Path to the SQLite database file |
ALLOWED_ORIGINS |
Comma-separated CORS allow-list. Empty = allow any origin (default; works on LAN HTTP) |
TRUST_PROXY |
true when behind a reverse proxy (for correct rate-limit client IPs) |
RATE_LIMIT_WINDOW_MS |
Rate-limit window in ms (default 15 min) |
RATE_LIMIT_MAX |
Max requests per IP per window (default 600) |
RATE_LIMIT_AUTH_MAX |
Max auth attempts per IP per window (default 30) |
ADMIN_EMAIL |
Built-in admin account email |
ADMIN_PASSWORD |
Built-in admin account password |
TELEGRAM_BOT_TOKEN |
BotFather token (empty = Telegram disabled) |
TELEGRAM_BOT_USERNAME |
Bot username without @ (for deep links & login widget) |
VITE_API_URL |
API base URL the client calls |
NGROK_AUTHTOKEN |
ngrok auth token for the optional public tunnel (Docker) |
BACKUP_TIME |
Daily backup time HH:MM (UTC) for the backup sidecar (default 03:00) |
BACKUP_RETENTION_DAYS |
Days of backups to keep before pruning (default 7) |
BACKUP_HOST_DIR |
Host folder backups are copied to, relative to docker/ (default ../backups) |
The API includes several production-hardening measures while remaining zero-config for local / LAN development:
- Helmet security headers (HSTS disabled so plain HTTP on a LAN still works).
- Configurable CORS — leave
ALLOWED_ORIGINSempty to reflect any origin (handy forhttp://localhostorhttp://192.168.x.x); set it in production to lock down to your client origin(s). - Rate limiting — a generous global limit plus a stricter limit on sign-in/sign-up to blunt credential stuffing.
- Input validation — all write endpoints validate request bodies with Zod schemas (email format, password length, date format, repeat settings, etc.).
- JWT secret guard — the server refuses to start in
NODE_ENV=productionifJWT_SECRETis still the default dev value.
For a LAN-only deployment over HTTP, no extra configuration is needed. For a public deployment, see the Deployment section above for the full production setup (HTTPS, locked-down CORS, reverse proxy, rate-limit tuning).
The Telegram bot lives in server/src/telegram/ and is
built with Telegraf. It stays completely disabled
until both TELEGRAM_BOT_TOKEN and TELEGRAM_BOT_USERNAME are set.
Setup
-
Create a bot with @BotFather and copy the token.
-
Put the token and the bot's username in
.env:TELEGRAM_BOT_TOKEN=123456:ABC-DEF... TELEGRAM_BOT_USERNAME=my_mtodo_bot
-
(For Log in with Telegram) tell BotFather the site domain via
/setdomain(e.g.localhostfor local dev or your real domain). -
Restart the server — the bot starts in long-polling mode automatically.
Linking an account — open Settings → Telegram in the web app, click
Link Telegram, then either open the bot via the deep link or send the shown
/start <code> to the bot. Once linked, the user gets a
Bot features — after linking, send /lists to:
- review the list of todo lists (with open-task counts),
- open a list to review its tasks,
- ➕ add a new task,
- ✔️ mark a task as completed (or reopen it).
Log in with Telegram — the sign-in page shows the official Telegram Login Widget. Authenticating creates/links an account and returns a normal JWT, so it behaves like any other session.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/auth/signup |
Create account |
| POST | /api/auth/signin |
Log in |
| GET | /api/auth/me |
Current user |
| GET | /api/auth/telegram/config |
Whether Telegram is enabled + bot username |
| POST | /api/auth/telegram |
Log in with Telegram Login Widget |
| POST | /api/auth/telegram/link-code |
Generate a one-time bot link code |
| POST | /api/auth/telegram/unlink |
Disconnect the linked Telegram account |
| GET | /api/lists |
List owned + shared lists |
| POST | /api/lists |
Create list |
| GET | /api/lists/:id/shares |
List members (owner only) |
| POST | /api/lists/:id/shares |
Share with a user by email |
| DELETE | /api/lists/:id/shares/:userId |
Revoke / leave a share |
| GET | /api/tags |
List tags |
| GET | /api/tasks?today=true |
Today's tasks |
| POST | /api/tasks |
Create task |
| PUT | /api/tasks/:id |
Update task |
| PATCH | /api/tasks/:id/toggle |
Toggle completion |
| DELETE | /api/tasks/:id |
Delete task |
| GET | /api/admin/users |
List users (admin only) |
| DELETE | /api/admin/users/:id |
Delete user (admin only) |