Savvy
Selfhosted expense tracker with full multi-currency support. One container โ done.
โก Quick Start
docker run -d -p 3000:80 -v savvy-data:/data truenormis/savvy:latest
Open localhost:3000 and create your account.
โจ Features
- Multi-currency โ any fiat or crypto, transfers between them
- Auto exchange rates โ currency rates updated automatically via API
- Recurring transactions โ scheduled payments (daily, weekly, monthly, yearly)
- Automation rules โ auto-categorize transactions based on conditions
- Debts โ track loans and borrowings with payment history
- Budgets โ set limits and track progress
- Categories & tags โ flexible organization
- Multi-user โ share with family or team, role-based access (admin/user)
- Rich analytics โ Sankey diagrams, heatmaps, net worth tracking, expense pace
- CSV import โ import transactions from bank exports with duplicate detection
- Backups โ create, restore and download database backups
- 2FA โ two-factor authentication via TOTP (Google Authenticator, etc.)
๐ฑ Mobile-Friendly
Fully responsive design built with ShadCN/UI โ track expenses from your phone right after purchase.
๐ Deployment
Docker Compose (Recommended)
services:
savvy:
image: truenormis/savvy:latest
container_name: savvy
restart: unless-stopped
ports:
- "3000:80"
volumes:
- savvy-data:/data
environment:
- APP_URL=https://savvy.yourdomain.com
- TZ=Europe/Kyiv
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/livez"]
interval: 10s
timeout: 3s
start_period: 60s
retries: 3
volumes:
savvy-data:
Environment Variables
| Variable | Description | Default |
|---|---|---|
APP_URL |
Public URL of your instance | http://localhost |
TZ |
Timezone | UTC |
Behind a Reverse Proxy
Set APP_URL to your public https:// URL. Savvy honors the X-Forwarded-Proto and X-Forwarded-For headers from the proxy, so HTTPS link generation and real client IPs work automatically โ no extra configuration needed.
Health Checks
Two probe endpoints are exposed for orchestrators and uptime monitoring (responses use the IETF application/health+json format):
| Endpoint | Purpose | Healthy | Unhealthy |
|---|---|---|---|
/livez |
Liveness โ the app process is up. Use it for container restart decisions. | 200 |
โ |
/readyz |
Readiness โ database is reachable and migrations are applied. Gate traffic. | 200 |
503 |
/livez stays up during maintenance mode; /readyz returns 503 so traffic drains while the instance is not ready.
With Traefik (HTTPS)
services:
savvy:
image: truenormis/savvy:latest
container_name: savvy
restart: unless-stopped
volumes:
- savvy-data:/data
environment:
- APP_URL=https://savvy.yourdomain.com
- TZ=Europe/Kyiv
labels:
- "traefik.enable=true"
- "traefik.http.routers.savvy.rule=Host(`savvy.yourdomain.com`)"
- "traefik.http.routers.savvy.entrypoints=websecure"
- "traefik.http.routers.savvy.tls.certresolver=letsencrypt"
- "traefik.http.services.savvy.loadbalancer.server.port=80"
networks:
- traefik
volumes:
savvy-data:
networks:
traefik:
external: true
With Nginx Proxy Manager
- Run Savvy on internal port:
services: savvy: image: truenormis/savvy:latest container_name: savvy restart: unless-stopped expose: - "80" volumes: - savvy-data:/data environment: - APP_URL=https://savvy.yourdomain.com networks: - npm-network
volumes: savvy-data:
networks: npm-network: external: true
2. In Nginx Proxy Manager, create proxy host pointing to `savvy:80`
### Kubernetes
Deploy as a single-replica `Deployment` with a `PersistentVolumeClaim` mounted at `/data`. SQLite is single-writer, so use `strategy: { type: Recreate }`. The container runs as non-root (`www-data`, uid 82) โ grant `NET_BIND_SERVICE` so it can bind port 80. Wire the probes to the health endpoints:
```yaml
startupProbe:
httpGet: { path: /livez, port: 80 }
periodSeconds: 3
failureThreshold: 30
livenessProbe:
httpGet: { path: /livez, port: 80 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /readyz, port: 80 }
periodSeconds: 10
Helm chart coming soon.
๐ Updating
docker compose pull
docker compose up -d
Your data is safe in the /data volume.
๐พ Backups
Backups can be managed directly from the UI (Settings โ Backups).
[!WARNING] The database runs in WAL mode, so recent writes may still live in the
database.sqlite-walfile and won't be indatabase.sqliteyet. Copyingdatabase.sqlitealone can silently lose the latest data. Always checkpoint the WAL into the main file first (or use the in-app backup, which handles this for you).
Manual backup:
# Fold the WAL into the main file, then copy
docker exec savvy php artisan tinker --execute="DB::statement('PRAGMA wal_checkpoint(TRUNCATE);');"
docker cp savvy:/data/database.sqlite ./backup-$(date +%Y%m%d).sqlite
Restore (stop writers first so the WAL doesn't fight the swap):
docker compose down
docker cp ./backup.sqlite savvy:/data/database.sqlite
docker compose up -d
๐ Privacy
Your data stays with you. SQLite database stored in /data volume โ no external services required.
โ๏ธ How It Works
One container runs everything under Supervisor โ Nginx, PHP-FPM, the scheduler (recurring transactions, automatic exchange-rate updates) and a queue worker for background jobs. SQLite lives in /data; no external database, cache, or queue service is required. Migrations run automatically on startup.
๐ Stack
Laravel โข SQLite โข Docker โข ShadCN/UI โข Tailwind CSS
๐ค Contributing
Contributions are welcome! Please open an issue first to discuss what you would like to change.
๐ License
Made with โค๏ธ for people who want control over their finances