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
37 changes: 25 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ on:
- main
pull_request:

# A newer push to main supersedes an in-flight build/deploy.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

env:
IMAGE: ghcr.io/richardhapb/finitum-api

# Lint/build for the same ref supersede each other. Deploy is handled by a
# SEPARATE, non-cancelable group below so a push never kills a deploy mid-`up -d`.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
lint-test:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -47,6 +48,9 @@ jobs:
permissions:
contents: read
packages: write
outputs:
# Immutable tag consumed by deploy. Single source of truth for what shipped.
sha_tag: sha-${{ github.sha }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
Expand All @@ -68,7 +72,7 @@ jobs:
images: ${{ env.IMAGE }}
tags: |
type=raw,value=latest
type=sha,prefix=sha-,format=short
type=sha,prefix=sha-,format=long

- name: Build and push
uses: docker/build-push-action@v6
Expand All @@ -84,17 +88,26 @@ jobs:
needs: build-push
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
# Deploys serialize but NEVER cancel each other — half a `docker compose up`
# is worse than a queued deploy.
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- name: Deploy to EC2
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
env:
IMAGE_TAG: ${{ needs.build-push.outputs.sha_tag }}
with:
host: ${{ secrets.HOST_ADDRESS }}
username: ${{ secrets.HOST_USER }}
key: ${{ secrets.HOST_SSH_KEY }}
# appleboy/ssh-action does not forward the runner env by default;
# pass what the remote script needs explicitly.
envs: IMAGE_TAG
# All deploy details (paths, users, compose invocation) live in a
# root-owned script on the server; the SSH user's sudoers is limited
# to exactly that script. Nothing sensitive appears in this file.
script: |
set -euo pipefail
cd ~/finitum
git pull --ff-only
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
docker image prune -f
sudo /usr/local/sbin/finitum-deploy "$IMAGE_TAG"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Finitum is an open-source personal finance tracker. Instead of scraping your bank or asking for your credentials, it uses the emails your bank already sends you: you set up a one-time forwarding rule, and every purchase, withdrawal, and transfer notification is parsed into a transaction (amount, merchant, date, category) and shown on your dashboard.

- **No inbox access.** Finitum never reads your email account. You forward only the bank notifications you choose; Google sign-in is optional and used for login only.
- **Community bank parsers.** Every bank's email format is described in a single JSON file -- no engine code needed. Adding your bank is a JSON block plus a couple of test fixtures. See [Adding a bank](docs/adding-a-bank.md).
- **Quick-implementation bank parsers.** Every bank's email format is described in a single JSON file -- no engine code needed. Adding your bank is a JSON block plus a couple of test fixtures. See [Adding a bank](docs/adding-a-bank.md).
- **Self-hostable.** One `docker compose up` runs the whole stack. A hosted instance lives at [finitum.app](https://finitum.app).
- **Privacy first.** Raw email content is processed in real time and never stored -- only the extracted transaction data.

Expand Down
8 changes: 4 additions & 4 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Production overrides - use with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up
services:
api:
image: ghcr.io/richardhapb/finitum-api:latest
image: ghcr.io/richardhapb/finitum-api:${IMAGE_TAG:-latest}
command: granian --interface asgi --workers ${API_WORKERS:-2} --host 0.0.0.0 --port 9090 api.server:app --access-log
ports: ["127.0.0.1:9090:9090"]
env_file: .env.prod
Expand All @@ -12,17 +12,17 @@ services:
volumes: !reset []

worker:
image: ghcr.io/richardhapb/finitum-api:latest
image: ghcr.io/richardhapb/finitum-api:${IMAGE_TAG:-latest}
command: celery -A tasks.app:celery worker --loglevel=INFO --concurrency=${CELERY_CONCURRENCY:-2}
env_file: .env.prod
volumes: !reset []

beat:
image: ghcr.io/richardhapb/finitum-api:latest
image: ghcr.io/richardhapb/finitum-api:${IMAGE_TAG:-latest}
env_file: .env.prod
volumes: !reset []

alembic:
env_file: .env.prod
image: ghcr.io/richardhapb/finitum-api:latest
image: ghcr.io/richardhapb/finitum-api:${IMAGE_TAG:-latest}
volumes: !reset []
25 changes: 14 additions & 11 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,27 @@ Operator notes for the CI/CD pipeline and the reference deployment ([finitum.app
Every push to `main` runs [.github/workflows/ci.yml](../.github/workflows/ci.yml):

1. **lint-test** -- `ruff check` + `pytest` (also runs on pull requests, as a gate).
2. **build-push** -- builds a locked image (`uv.lock` + `uv sync --frozen`) and pushes `ghcr.io/richardhapb/finitum-api` tagged `latest` and `sha-<short>`.
3. **deploy** -- SSHes into the VPS and runs:
2. **build-push** -- builds a locked image (`uv.lock` + `uv sync --frozen`) and pushes `ghcr.io/richardhapb/finitum-api` tagged `latest` and `sha-<sha>`. The immutable sha tag is what deploys.
3. **deploy** -- SSHes into the VPS as a low-privilege deploy user and runs a single command:

```bash
cd ~/finitum
git pull --ff-only
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
docker image prune -f
sudo /usr/local/sbin/finitum-deploy "$IMAGE_TAG"
```

The image is self-contained (code + deps + alembic migrations baked in); `git pull` on the server only refreshes the compose/env files. Prod uses `volumes: !reset []` so no host source is mounted -- the server runs exactly what CI built.
All environment-specific detail (paths, users, the compose invocation) lives in the root-owned `finitum-deploy` script on the server -- the public workflow file contains no infrastructure information. The script validates the image tag, does `git pull --ff-only` as the app user (only compose/config files come from git; app code lives in the image), then `docker compose pull` + `up -d` with `IMAGE_TAG` exported, and prunes only this repo's images. Prod uses `volumes: !reset []` so no host source is mounted -- the server runs exactly what CI built.

## Security model

- The application files are owned by a dedicated **app user** with `nologin` -- nobody can SSH in as it, and its `.env` files are mode 0600.
- CI authenticates as a separate **deploy user** whose sudoers entry permits exactly one command: the `finitum-deploy` script. A leaked deploy key can trigger a deploy and nothing else. Its SSH key is restricted (`no-port-forwarding,no-agent-forwarding,no-X11-forwarding`).
- The deploy script whitelists tags (`latest` or `sha-<hex>`), so arguments can't be used to smuggle commands across the sudo boundary.
- The server repo's `origin` uses HTTPS (anonymous read-only -- the repo is public), so no GitHub credentials exist on the box.

## One-time setup

- **Make the package public**: GitHubPackages → `finitum-api`Package settings → Change visibility → **Public**. The server then pulls with no auth.
- **Add repository secrets** (Settings → Secrets and variables → Actions): `HOST_ADDRESS`, `HOST_USER`, and `HOST_SSH_KEY` (a private key whose public half is in the server's `~/.ssh/authorized_keys`).
- **On the server**: clone the repo to `~/finitum` on `main`, populate `.env.prod` (including `CREDENTIALS_ENCRYPTION_KEY` -- back it up, losing it makes stored Google tokens unrecoverable), and confirm `docker compose version` ≥ 2.24 (required for `!reset`).
- **Repository secrets** (SettingsSecrets and variablesActions): `HOST_ADDRESS`, `HOST_USER` (the deploy user), and `HOST_SSH_KEY` (its private key).
- **On the server** (as root): create the `nologin` app user owning the app directory (clone the repo there on `main` over HTTPS, populate `.env.prod`; back up `CREDENTIALS_ENCRYPTION_KEY` -- losing it makes stored Google tokens unrecoverable); install `finitum-deploy` to `/usr/local/sbin` (root-owned, mode 0700) with the app path/user set inside; create the deploy user with the restricted authorized_keys entry and a sudoers file allowing only that script (`visudo -cf` it). Confirm `docker compose version` ≥ 2.24 (required for `!reset`).
- **Smoke test** without deploying: `sudo /usr/local/sbin/finitum-deploy latest --check` (validates git access and compose config, changes nothing).

## Email worker

Expand Down
Loading