Owing + Elending + Ith — a self-hosted app for tracking what you owe, what you’re owed, and your financial history.
Owelith is a self-hosted personal-finance and debt-tracking application. A Go backend (REST + gRPC) serves a React single-page UI from the same binary, so the whole product ships as one container image. Track accounts, transactions, categories, savings / repayment goals, and checklists — backed by a wallet with an immutable transaction history and role-based access control.
CASH (prepaid) and CREDIT (postpaid) accounts with running balances.ADMIN (manage users, system data) and USER (own data), enforced by middleware + ownership checks./ui.| Area | Technology |
|---|---|
| Language | Go 1.26 |
| REST | Gin |
| gRPC | google.golang.org/grpc + protobuf |
| ORM / DB | GORM — PostgreSQL (default), MySQL, SQLite |
| Cache / tokens | Redis (go-redis/v9) |
| Config | Viper (env-based) |
| Logging | Zap (structured JSON) |
| Auth | golang-jwt/jwt/v5 + crypto/ed25519 + bcrypt |
| Money | shopspring/decimal |
| Tracing | OpenTelemetry (otelkit) |
| CLI | urfave/cli v1 |
| Frontend | React 18 + TypeScript + Vite |
| UI | Tailwind CSS, TanStack Query, Zustand, React Router, lucide-react |
| Container | Distroless, multi-stage build |
┌────────────────────────────────────────────┐
Browser ─────▶ │ Go binary (single image) │
:8080/ui │ │
│ app/rest Gin HTTP handlers │
│ app/grpc gRPC handlers │
│ service business logic │
│ repository data access │
│ models GORM entities │
└───────┬───────────────────────┬────────────┘
│ │
┌──────▼──────┐ ┌───────▼───────┐
│ PostgreSQL │ │ Redis │
│ (GORM) │ │ (token store) │
└─────────────┘ └───────────────┘
The backend is layered so each concern has one home:
| Layer | Responsibility | Path |
|---|---|---|
| Transport | HTTP/gRPC request handling & serialization | app/rest, app/grpc |
| Service | Business logic, validation, orchestration | service/ |
| Repository | Data access, queries, transactions | repository/ |
| Models | Domain entities & GORM tags | models/ |
| Frontend | React SPA served as static files at /ui |
webui/ |
owelith/
├── main.go # Entry point — CLI commands (start, api, grpc)
├── config/ # Centralized config (Viper)
├── constants/ # App-wide constants (env names, roles…)
├── instance/ # Singleton init (DB, Redis, registry, migrations)
├── logger/ # Structured logging
├── runner/ # REST + gRPC server runners
├── utils/ # Shared utilities (banner, tracing, keys…)
├── errors/ · errorslug/ # Typed errors + error slugs
├── models/ # GORM entities
├── repository/ # Data-access layer
├── service/ # Business-logic layer
├── app/
│ ├── rest/ # Gin controllers, router, middlewares
│ └── grpc/ # gRPC handlers, proto, router
├── webui/ # React + Vite frontend (builds into ../public/ui)
├── public/ui/ # Built frontend (served by the backend at /ui)
├── migrations/ # SQL migrations (reference)
├── docker/ # Backend Dockerfiles (dev, debug, prod)
├── docker-compose*.yml # Compose files per environment
└── Makefile # Build / test / run / docker targets
make (optional, but used throughout)cp .env.example .env
Generate a JWT signing key and put it in .env (see
Generate a JWT signing key for details):
openssl genpkey -algorithm Ed25519 -out ed25519.pem
printf 'JWT_PRIVATE_KEY=%s\n' \
"$( (openssl pkey -in ed25519.pem -outform DER | tail -c 32; \
openssl pkey -in ed25519.pem -pubout -outform DER | tail -c 32) | base64 -w0 )"
docker compose -f docker-compose.yml -f docker-compose-dev.yml up -d postgres redis
This exposes Postgres on localhost:5432 and Redis on localhost:6379, matching the
defaults in .env.example.
make setup # go mod tidy
make frontend-setup # cd webui && npm install
make run # go run main.go start (REST + gRPC)
Open http://localhost:8080/ui.
For frontend hot-reload during development:
make frontend-dev # Vite dev server on http://localhost:5173
Or use the fully Dockerised dev stack (backend hot-reload via
modd+ Vite):make docker-up-dev.
Production deployment runs three containers — backend (API + UI), PostgreSQL, and Redis — on a single host. The backend image already bundles the built React UI, so no separate web server is required.
| Requirement | Notes |
|---|---|
| Docker Engine | 24+ |
| Docker Compose | v2 (docker compose, not docker-compose) |
openssl |
to generate the JWT key |
| Resources | ≥ 1 vCPU, ≥ 2 GB RAM, ≥ 5 GB disk |
| Ports | 8080 (UI + REST API), 9090 (gRPC) — open/free on the host |
Owelith signs JWTs with Ed25519. JWT_PRIVATE_KEY must be the base64 encoding of the
raw 64-byte private key (seed ‖ public). This produces exactly that using only openssl:
openssl genpkey -algorithm Ed25519 -out ed25519.pem
export JWT_PRIVATE_KEY="$(
( openssl pkey -in ed25519.pem -outform DER | tail -c 32
openssl pkey -in ed25519.pem -pubout -outform DER | tail -c 32
) | base64 -w0
)"
echo "$JWT_PRIVATE_KEY"
# sanity check → must print 64
echo -n "$JWT_PRIVATE_KEY" | base64 -d | wc -c
macOS:
base64 -w0isn’t available — usebase64 | tr -d '\n'instead.
Keep ed25519.pem in a secure place (or delete it after copying the base64 value into your
.env). Rotating this key invalidates all existing sessions.
.envsudo mkdir -p /opt/owelith && cd /opt/owelith
cat > .env <<EOF
DB_NAME=owelith
DB_USER=owelith
DB_PASS=$(openssl rand -hex 16)
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}
IS_REGISTRATION_ENABLED=true
EOF
chmod 600 .env
Save the following as /opt/owelith/docker-compose.yml. It pulls the published image from
GitHub Container Registry (no source checkout needed).
services:
postgres:
image: postgres:16-alpine
container_name: owelith-postgres
environment:
POSTGRES_DB: ${DB_NAME:-owelith}
POSTGRES_USER: ${DB_USER:-owelith}
POSTGRES_PASSWORD: ${DB_PASS:-owelith}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-owelith} -d ${DB_NAME:-owelith}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: owelith-redis
command: redis-server --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
backend:
image: ghcr.io/tech-thinker/owelith:latest # or pin a release: :1.2.3
container_name: owelith-backend
environment:
- APP_NAME=owelith
- APP_ENV=production
- API_PORT=8080
- GRPC_PORT=9090
- DB_DRIVER=postgres
- DB_HOST=postgres
- DB_PORT=5432
- DB_USER=${DB_USER:-owelith}
- DB_PASS=${DB_PASS:-owelith}
- DB_NAME=${DB_NAME:-owelith}
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_DB=0
- JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}
- JWT_ACCESS_EXPIRY=15
- JWT_REFRESH_EXPIRY=10080
- IS_REGISTRATION_ENABLED=${IS_REGISTRATION_ENABLED:-true}
- CORS_ORIGINS=*
- UI_ENABLED=true
ports:
- "8080:8080" # UI + REST API
- "9090:9090" # gRPC
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
volumes:
postgres_data:
redis_data:
Building from source instead? Clone the repo and use the checked-in
docker-compose.yml, then rundocker compose up -d --build(ormake docker-deploy). It additionally starts an nginx-served copy of the UI on:8081, but the UI athttp://<host>:8080/uiworks either way.
cd /opt/owelith
# If the GHCR package is private, authenticate first:
# echo <GITHUB_PAT_with_read:packages> | docker login ghcr.io -u <github-user> --password-stdin
docker compose up -d
docker compose ps
docker compose logs -f backend # watch startup / migrations
The backend creates/updates its schema automatically on boot. Wait until
docker compose ps shows backend healthy and the logs report the server is listening.
There is no default/bootstrapped account, and self-registration creates a USER. So:
a. Register the first account (via the UI at http://<host>:8080/ui/register, or the API):
curl -X POST http://localhost:8080/gateway/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"admin","email":"admin@example.com","name":"Administrator","password":"ChangeMe123!","deviceId":"setup"}'
b. Promote it to ADMIN:
docker compose exec postgres \
psql -U "${DB_USER:-owelith}" -d "${DB_NAME:-owelith}" \
-c "UPDATE users SET role='ADMIN' WHERE username='admin';"
c. Log out and back in so the new token carries the ADMIN role. You can now manage
users from Users in the sidebar.
Once your admin exists, set
IS_REGISTRATION_ENABLED=falsein.envand rundocker compose up -dto close public sign-ups.
| Service | URL / address |
|---|---|
| Web UI | http://<host>:8080/ui |
| REST API | http://<host>:8080/api/v1/…, http://<host>:8080/gateway/auth/… |
| Health | http://<host>:8080/api/v1/health |
| gRPC | <host>:9090 |
Terminate TLS at a reverse proxy and forward to 127.0.0.1:8080. Example with Caddy:
# /etc/caddy/Caddyfile
owelith.example.com {
reverse_proxy 127.0.0.1:8080
}
Then bind the backend to localhost only by changing its ports mapping to
"127.0.0.1:8080:8080" and "127.0.0.1:9090:9090". Set CORS_ORIGINS to your public
origin (e.g. https://owelith.example.com) instead of *.
cd /opt/owelith
docker compose pull # fetch the newest image
docker compose up -d # recreate containers; schema migrates on boot
To pin a release, set image: ghcr.io/tech-thinker/owelith:1.2.3 (or 1.2, 1) in the
Compose file and docker compose up -d.
# Backup
docker compose exec -T postgres pg_dump -U owelith owelith > owelith-$(date +%F).sql
# Restore
cat owelith-2026-01-01.sql | docker compose exec -T postgres psql -U owelith -d owelith
Redis holds only ephemeral tokens, so backing up Postgres is sufficient. For extra safety,
back up the postgres_data and redis_data volumes too.
docker compose down # stop & remove containers (data volumes kept)
docker compose down -v # also delete the database/redis volumes (destructive)
All configuration is environment-driven. The values below are used by the container deployment.
| Variable | Default | Description |
|---|---|---|
VERSION |
1.0.0 |
Reported app version |
APP_NAME |
owelith |
Service name (logs/tracing) |
APP_ENV |
production |
development | staging | production |
TZ |
UTC |
Container timezone |
API_PORT |
8080 |
REST HTTP port |
GRPC_PORT |
9090 |
gRPC port |
CORS_ORIGINS |
* |
Comma-separated allowed origins |
DB_DRIVER |
postgres |
postgres | mysql | sqlite |
DB_HOST |
postgres |
Database host |
DB_PORT |
5432 |
Database port |
DB_USER |
owelith |
Database user |
DB_PASS |
— | Database password |
DB_NAME |
owelith |
Database name |
REDIS_HOST |
redis |
Redis host |
REDIS_PORT |
6379 |
Redis port |
REDIS_PASSWORD |
(empty) | Redis password |
REDIS_DB |
0 |
Redis logical database |
JWT_PRIVATE_KEY |
— | Required. Base64 of the raw 64-byte Ed25519 private key |
JWT_ACCESS_EXPIRY |
15 |
Access-token lifetime (minutes) |
JWT_REFRESH_EXPIRY |
10080 |
Refresh-token lifetime (minutes; 7 days) |
IS_REGISTRATION_ENABLED |
true |
Allow public self-registration |
UI_ENABLED |
true |
Serve the built SPA from the backend at /ui |
OTEL_ENABLED |
false |
Enable OpenTelemetry export |
OTEL_EXPORTER_URL |
— | OTLP endpoint (e.g. http://otel-collector:4317) |
OTEL_PROTOCOL |
grpc |
grpc | http |
Base URL: http://<host>:8080. All authenticated endpoints expect
Authorization: Bearer <accessToken>.
/gateway/auth| Method | Path | Description |
|---|---|---|
POST |
/gateway/auth/register |
Create a USER account |
POST |
/gateway/auth/login |
Exchange credentials for an access + refresh token |
POST |
/gateway/auth/renew_access_token |
Mint a new access token |
POST |
/gateway/auth/renew_refresh_token |
Rotate the refresh token |
POST |
/gateway/auth/logout |
Revoke the current session |
/api/v1| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
GET/PUT |
/profile |
Read / update the current profile |
POST |
/profile/change-password |
Change password |
* |
/accounts |
Wallet/account management |
* |
/transactions |
Transaction listing & creation |
* |
/categories |
Category CRUD |
* |
/goals |
Goals CRUD |
* |
/checklists |
Checklists CRUD |
GET/POST/PUT/PATCH |
/users, /users/:id, /users/:id/status |
Admin only user management |
gRPC exposes a health service on port 9090.
curl -X POST http://localhost:8080/gateway/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"ChangeMe123!","deviceId":"cli"}'
docker compose ps # container status
docker compose logs -f backend # follow backend logs
docker compose restart backend # restart just the API
docker compose exec backend sh # shell into the container (distroless: may be absent)
info in production, debug in development).GET /api/v1/health returns the status of dependencies.OTEL_ENABLED=true and point OTEL_EXPORTER_URL at an OTLP collector
(e.g. Jaeger/Tempo) to export traces.# Backend
make run # run REST + gRPC
go run main.go api # REST only
go run main.go grpc # gRPC only
make build # build ./owelith binary
# Frontend
make frontend-dev # Vite dev server (:5173, proxies API to backend)
make frontend-build # build into ../public/ui (served by the backend)
make frontend-lint
make frontend-type-check
# Docker
make docker-up-dev # dev stack: backend hot-reload + Vite
make docker-up # production stack (build from source)
make docker-up-staging
make docker-up-debug # backend under Delve on :2345
make docker-config # validate every compose configuration
make docker-clean # stop everything and remove volumes
Follow the layered pattern: Model → Repository → Service → Transport, then wire it into
the registry (instance/registry/models.go + instance/instance.go) and expose it via a
controller (app/rest/controllers/) and/or gRPC handler. See AGENTS.md for the full
convention guide.
# Backend (race detector + coverage)
make test
go test -v ./service/... -race
# Coverage report
make coverage-html && make coverage-serve
# Frontend
cd webui && npm run type-check
See the repository for license information.