Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ A self-hosted investment tracking platform for managing portfolios, analyzing pe

## Preview

[folio-demo.webm](https://github.com/user-attachments/assets/bb43d330-5571-4a6c-aeb7-e9ac58a1e26a)
[folio-demo.webm](https://github.com/user-attachments/assets/59817665-1af4-4d10-b0fa-cb92d40d8e04)

## Stack

| Layer | Technology |
|----------|-----------------------------------|
| Frontend | SvelteKit, TypeScript, Tailwind, shadcn-svelte, bits-ui |
| Backend | FastAPI, SQLAlchemy (async) |
| Cache | Valkey (Redis-compatible) |
| Database | PostgreSQL |
| Data | yfinance, tiingo, ngnmarket, tradingview |
| Infra | Docker, Docker Compose |
Expand All @@ -63,7 +64,7 @@ If you just want to run Folio locally for personal use, pull the pre-built image
make prod-setup
```

Open `api/.env.prod` and `web/.env.local` and fill in your values (database credentials, API URL, `SECRET_KEY`, etc.), then:
Open `api/.env.prod` and `web/.env.prod` and fill in your values. The provided examples already include the correct Docker service hostnames for the database (`db`) and cache (`cache`) — you only need to set `SECRET_KEY` and any API keys you plan to use. A local PostgreSQL database and Valkey cache are bundled in the stack. You can override database credentials via the Compose environment variables (`DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT`). Then:

```bash
make prod-pull
Expand All @@ -82,8 +83,8 @@ Once running:
| Service | URL |
|----------|----------------------------|
| Frontend | http://localhost:3000 |
| API | http://localhost:8000 |
| API Docs | http://localhost:8000/docs |
| API | http://localhost:8010 |
| API Docs | http://localhost:8010/docs |

### Running for Development

Expand Down
4 changes: 4 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ REFRESH_TOKEN_EXPIRE_DAYS=7
YFINANCE_CACHE_TTL=3600
YFINANCE_PRICE_HISTORY_CACHE_TTL=86400
MARKET_DATA_CACHE_TTL=108000

# Market data
TIINGO_API_KEY=value
NGNMARKET_API_BASE_URL=https://api.ngnmarket.com/v1/
NGNMARKET_API_KEY=value
RAPID_API_BASE_URL=https://tradingview-data1.p.rapidapi.com/
RAPID_API_KEY=value

# Scheduler
SCHEDULER_ENABLED=true
Expand Down
12 changes: 7 additions & 5 deletions api/.env.prod.example
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
# Platform
DATABASE_URL=
REDIS_URL=
DATABASE_URL=postgresql+asyncpg://folio:folio_dev_password@db:5432/folio_prod
REDIS_URL=redis://cache:6379/0
ENABLE_REGISTRATION=false

# API
SECRET_KEY=secret-key
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
API_ENV=production
API_PORT=8000
API_PORT=8010

# Auth
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7

# Market data
# Market cache data
YFINANCE_CACHE_TTL=3600
YFINANCE_PRICE_HISTORY_CACHE_TTL=86400
MARKET_DATA_CACHE_TTL=108000
MARKET_DATA_CACHE_TTL=10800

# Market data
TIINGO_API_KEY=value
NGNMARKET_API_BASE_URL=https://api.ngnmarket.com/v1/
NGNMARKET_API_KEY=value
Expand Down
4 changes: 3 additions & 1 deletion api/infrastructure/config/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,16 @@ class EnvironSettings(BaseModel):
# Leave empty for same-origin or reverse-proxy setups.
COOKIE_DOMAIN: str = os.getenv("COOKIE_DOMAIN", "")

# Market data
# Market cache data
YFINANCE_CACHE_TTL: int = int(os.getenv("YFINANCE_CACHE_TTL", "3600")) # 1 hour
YFINANCE_PRICE_HISTORY_CACHE_TTL: int = int(
os.getenv("YFINANCE_PRICE_HISTORY_CACHE_TTL", "86400")
) # 1 day
MARKET_DATA_CACHE_TTL: int = int(
os.getenv("MARKET_DATA_CACHE_TTL", "10800")
) # 3 hours — cached in Valkey

# Market data
TIINGO_API_KEY: str = os.getenv("TIINGO_API_KEY", "")
NGNMARKET_API_BASE_URL: str = os.getenv(
"NGNMARKET_API_BASE_URL", "https://api.ngnmarket.com/v1"
Expand Down
41 changes: 25 additions & 16 deletions api/infrastructure/db/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from infrastructure.db.session import async_session

_SEED_USER_ID = UUID("00000000-0000-0000-0000-000000000001")
_SEED_USER_EMAIL = "demo@folio.local"
_SEED_USER_EMAIL = "demo@example.com"

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -193,7 +193,7 @@ def _price_on(prices: dict[date, int], d: date) -> int:

def _build_trades(
portfolio_id,
asset_map: dict[str, uuid4],
asset_map: dict[str, UUID],
price_map: dict[str, dict[date, int]],
) -> list[dict]:

Expand Down Expand Up @@ -340,23 +340,32 @@ async def seed() -> None:

log.info("Seeding demo data…")

# Ensure seed user exists
existing_user = await session.execute(
select(UserModel).where(UserModel.id == _SEED_USER_ID)
)
if not existing_user.scalar_one_or_none():
import bcrypt

hashed = bcrypt.hashpw(b"demo1234", bcrypt.gensalt()).decode("utf-8")
session.add(
UserModel(
id=_SEED_USER_ID,
# Ensure seed user exists (may have been pre-created by a migration
# with a placeholder password and is_active=False). Upsert handles
# both create and overwrite in a single statement.
import bcrypt
from sqlalchemy.dialects.postgresql import insert as pg_insert

hashed = bcrypt.hashpw(b"demo1234", bcrypt.gensalt()).decode("utf-8")
stmt = (
pg_insert(UserModel)
.values(
id=_SEED_USER_ID,
email=_SEED_USER_EMAIL,
hashed_password=hashed,
is_active=True,
)
.on_conflict_do_update(
index_elements=["id"],
set_=dict(
email=_SEED_USER_EMAIL,
hashed_password=hashed,
is_active=True,
)
),
)
await session.flush()
)
await session.execute(stmt)
await session.flush()

# Portfolio
portfolio_id = uuid4()
Expand All @@ -374,7 +383,7 @@ async def seed() -> None:
rng = random.Random(_RNG_SEED)
weekdays = _weekdays(_START, _END)
price_map: dict[str, dict[date, int]] = {}
asset_map: dict[str, uuid4] = {}
asset_map: dict[str, UUID] = {}

for a in _ASSETS:
asset_id = uuid4()
Expand Down
2 changes: 1 addition & 1 deletion api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "folio-api"
version = "1.22.2"
version = "1.22.3"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
Expand Down
2 changes: 1 addition & 1 deletion api/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 56 additions & 10 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,62 @@
services:
db:
image: postgres:16-alpine
container_name: folio-prod-db
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-folio}
POSTGRES_PASSWORD: ${DB_PASSWORD:-folio_dev_password}
POSTGRES_DB: ${DB_NAME:-folio_prod}
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "${DB_PORT:-5433}:5432"
volumes:
- postgres_prod_data:/var/lib/postgresql/data
- ./infra/scripts/ensure-db-entrypoint.sh:/ensure-db-entrypoint.sh:ro
entrypoint: ["/ensure-db-entrypoint.sh"]
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${DB_USER:-folio} -d ${DB_NAME:-folio_prod}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- folio-prod-network

cache:
image: valkey/valkey:9.0.4
container_name: folio-prod-cache
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning
volumes:
- folio_prod_cache_data:/var/lib/redis/data
networks:
- folio-prod-network
expose:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3

api:
image: ghcr.io/aybruhm/folio-api:latest
container_name: folio-ghcr-api
restart: unless-stopped
env_file: ./api/.env.prod
environment:
DATABASE_URL: ${DATABASE_URL}
ports:
- "${API_PORT:-8000}:8000"
- "${API_PORT:-8010}:8000"
depends_on:
db:
condition: service_healthy
command: >
python -m alembic upgrade head &&
python -m uvicorn main:app
--host 0.0.0.0
--port ${API_PORT:-8000}
--workers 2
cache:
condition: service_healthy
command: python -m uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2
networks:
- folio-prod-network
healthcheck:
Expand All @@ -41,7 +81,7 @@ services:
image: ghcr.io/aybruhm/folio-web:latest
container_name: folio-ghcr-web
restart: unless-stopped
env_file: ./web/.env.local
env_file: ./web/.env.prod
ports:
- "${WEB_PORT:-3000}:3000"
depends_on:
Expand All @@ -60,3 +100,9 @@ networks:
folio-prod-network:
name: folio-prod-network
driver: bridge

volumes:
postgres_prod_data:
driver: local
folio_prod_cache_data:
driver: local
43 changes: 43 additions & 0 deletions infra/scripts/ensure-db-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/bin/sh
# ---------------------------------------------------------------------------
# Custom PostgreSQL entrypoint wrapper
#
# Starts PostgreSQL via the original entrypoint, waits for readiness, then
# ensures $POSTGRES_DB exists (creating it if missing). Finally, brings
# PostgreSQL back to the foreground so the container stays alive.
#
# All POSTGRES_* environment variables are injected by docker-compose.
# ---------------------------------------------------------------------------
set -e

# Start the original PostgreSQL entrypoint in the background.
# We capture its PID so we can bring it to the foreground later.
/usr/local/bin/docker-entrypoint.sh postgres &
POSTGRES_PID=$!

# Wait until PostgreSQL is accepting connections.
echo "Waiting for PostgreSQL to become ready..."
until pg_isready -U "${POSTGRES_USER:-folio}" -d postgres > /dev/null 2>&1; do
sleep 2
done
echo "PostgreSQL is ready."

# Resolve the target database name (same default as docker-compose.prod.yml).
DB_NAME="${POSTGRES_DB:-folio_prod}"
DB_USER="${POSTGRES_USER:-folio}"

# Check whether the database already exists.
EXISTS=$(psql -U "$DB_USER" -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname = '$DB_NAME';" 2>/dev/null || true)

if [ "$EXISTS" = "1" ]; then
echo "Database '$DB_NAME' already exists. Nothing to do."
else
echo "Database '$DB_NAME' does not exist. Creating..."
createdb -U "$DB_USER" "$DB_NAME"
echo "Database '$DB_NAME' created successfully."
fi

# Bring PostgreSQL back to the foreground.
# If postgres exits, the container exits (as expected).
wait $POSTGRES_PID
21 changes: 21 additions & 0 deletions web/.env.prod.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# API Configuration
# Secret key should be the same with api/.env.prod (if in production)
PUBLIC_API_BASE_URL=/api/v1/
PRIVATE_API_URL=http://api:8000
SECRET_KEY=dev-secret-key-change-in-production

# Node Environment
NODE_ENV=production

# Enable/disable the sign-up page and registration API.
# Set to "false" to hide the register page and reject
# registration API calls (e.g. in production).
PUBLIC_ENABLE_REGISTRATION=true

# Client-side IndexedDB cache TTLs (in seconds).
# These should mirror the corresponding backend MARKET_DATA_CACHE_TTL,
# YFINANCE_PRICE_HISTORY_CACHE_TTL, and YFINANCE_CACHE_TTL values in api/.env.prod.
PUBLIC_MARKET_DATA_CACHE_TTL=10800
PUBLIC_PRICE_HISTORY_CACHE_TTL=86400
PUBLIC_YFINANCE_CACHE_TTL=3600
PUBLIC_FX_RATES_CACHE_TTL=10800
1 change: 1 addition & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ build/
.env
.env.*
!.env.local.example
!.env.prod.example
4 changes: 2 additions & 2 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "folio-web",
"version": "1.22.2",
"version": "1.22.3",
"type": "module",
"scripts": {
"dev": "svelte-kit sync && vite",
Expand Down
Loading