The JWT lifetime is now pinned to SESSION_TTL_SECONDS (30 days)
inside issueSessionCookie, so JWT_EXPIRY had no effect after the
prior commits. Removing the field from Config and the env read in
loadConfig lets the TypeScript compiler verify no caller was still
relying on it.
Per the family invariant ('coherence across apps is a feature'),
the per-app override is intentionally gone — a deploy with stale
JWT_EXPIRY in .env will now silently use the 30-day family default
regardless of the value.
Test fixtures (Config literals in setup.ts, login-handler.test.ts,
lockout-service.test.ts) and config.test.ts assertions updated to
match the new shape.
Nanodrop
Vibe coded alternative to Google Drive for sharing files
- Server-rendered HTML, no client-side framework
- SQLite database, files stored on disk
- JWT authentication via httpOnly cookies
- REST API alongside the web UI
Stack
- Runtime: Node.js 22
- Framework: Fastify
- Database: SQLite (better-sqlite3)
- Language: TypeScript (ESM, run directly via tsx)
Quick start (local)
npm install
JWT_SECRET=changeme npm run dev
Create a user first:
npm run register-user -- --username alice --password secret
Then open http://localhost:3000.
Docker
Build and run
docker compose up -d
Requires a .env file (or environment variables) with at minimum:
JWT_SECRET=your-secret-here
All data (database + uploads) is stored in the nanodrop-data Docker volume.
Register a user in Docker
docker compose run --rm register-user --username alice --password secret
Environment variables
| Variable | Default | Description |
|---|---|---|
JWT_SECRET |
(required) | Secret key for signing JWTs |
JWT_EXPIRY |
7d |
JWT token lifetime |
PORT |
3000 |
Port to listen on |
HOST |
0.0.0.0 |
Host to bind |
BASE_URL |
http://localhost:3000 |
Public base URL (used in share links) |
DB_PATH |
./data/nanodrop.db |
SQLite database path |
UPLOAD_DIR |
./data/uploads |
Upload storage directory |
LOG_FILE |
./data/nanodrop.log |
Auth and access log path |
MAX_FILE_SIZE |
104857600 |
Max upload size in bytes (100 MB) |
COOKIE_SECURE |
false |
Set true when serving over HTTPS |
TRUST_PROXY |
false |
Set true when behind a reverse proxy |
Reverse proxy
Set TRUST_PROXY=true when running behind a reverse proxy so Nanodrop sees the real client IP in logs.
Caddy (Caddyfile):
files.example.com {
reverse_proxy localhost:3000
request_body {
max_size 110MB
}
}
Caddy sets X-Forwarded-For and handles TLS automatically. Set COOKIE_SECURE=true since traffic to the app arrives over HTTPS.
nginx (sites-available/nanodrop):
server {
server_name files.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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;
client_max_body_size 110M;
}
}
Fail2ban
Nanodrop logs failed login attempts to LOG_FILE (default ./data/nanodrop.log) in this format:
[2026-03-03T12:00:00.000Z] AUTH_FAILURE ip=203.0.113.42 user-agent="curl/8.0" username="admin"
1. Create a filter
/etc/fail2ban/filter.d/nanodrop.conf:
[Definition]
failregex = ^.* AUTH_FAILURE ip=<HOST> .*$
ignoreregex =
2. Create a jail
/etc/fail2ban/jail.d/nanodrop.conf:
[nanodrop]
enabled = true
filter = nanodrop
logpath = /path/to/nanodrop.log
maxretry = 5
findtime = 60
bantime = 600
Adjust logpath to wherever your LOG_FILE is. With Docker, the log file lives inside the nanodrop-data volume — mount it to a host path or bind-mount a host directory instead of the named volume to make it accessible to fail2ban:
# docker-compose.yml override
volumes:
- /var/lib/nanodrop:/app/data
Then set logpath = /var/lib/nanodrop/nanodrop.log.
3. Reload fail2ban
sudo fail2ban-client reload
sudo fail2ban-client status nanodrop
Development
npm run dev # start with hot reload via tsx
npm test # run all tests (vitest)
npm run build # type check (tsc --noEmit)