Published March 16, 2026
How due.box Is Built
due.box is a Phoenix LiveView app that talks to EVM blockchains. One server, one database, one deploy pipeline. Here is what is in the stack and why.
Elixir and Phoenix
The backend is Elixir on the BEAM. Phoenix LiveView handles the frontend. There is almost no custom JavaScript: the only JS that runs in the browser is wallet signing via Viem and analytics via PostHog. Everything else is server-rendered HTML over a WebSocket.
LiveView is a good fit because the app is mostly forms and state. You create a commitment, see its status, mark it done. There is no complex client-side interaction that would justify a React or Svelte frontend. The server holds the state, renders the HTML, and pushes diffs. The result is fast page loads with no loading spinners.
Ash Framework
The domain layer uses Ash Framework. Ash is an Elixir library for building resource-oriented APIs. Instead of writing Ecto schemas, contexts, and controllers separately, you define resources with actions, attributes, and policies in one place. Ash generates the database queries, the JSON API endpoints, and the form helpers.
The app has four domains: Accounts (users, sessions, API tokens), Commitments (goals, occurrences, penalties), ChainNonces (per-chain transaction ordering), and Feedback (feature requests and voting). Each domain is self-contained with its own resources and actions.
Ash also handles audit logging via AshPaperTrail. Every change to a commitment or user is versioned automatically.
How penalties work
The penalty system is the core of the product. Here is the flow:
- A user creates a commitment with a deadline and a stake amount.
- The user grants an ERC-20 token allowance (e.g., USDC) to the due.box collector address from their wallet.
- An Oban cron job (
PenaltyScheduler) runs every minute. It queries for commitments wherenext_deadlinehas passed and the target was not met. - For each missed deadline, it creates a penalty occurrence and enqueues a
PenaltyProcessorjob. - The processor checks the user's token balance and allowance, estimates gas, and executes the penalty via
transferFrom()on the ERC-20 contract.
If the balance or allowance is insufficient, or if gas would cost more than the penalty, the commitment is archived.
Blockchain interaction
due.box is not a smart contract protocol. It does not deploy custom contracts. It uses standard ERC-20 token operations (balanceOf, allowance, transferFrom) to check and collect penalties.
The app supports seven chains: Ethereum, Optimism, Arbitrum, Base, and their testnets. Each chain has its own RPC endpoint, token addresses, and gas estimation logic.
The Elixir library Ethers handles contract calls on the backend. Viem handles wallet interaction on the frontend.
Authentication: Sign In With Ethereum
There are no passwords and no OAuth. Users sign in by signing a message with their wallet. The app generates a SIWE (EIP-4361) message containing a nonce, domain, chain ID, and timestamp. The user signs it in their browser wallet, the server recovers the address from the signature, and creates or finds the matching account.
The signature is verified server-side using secp256k1. The message must be less than five minutes old. After signing in, the user gets a standard Phoenix session token.
There is also email verification (optional) and magic link sign-in for users who add an email later. API tokens are available for automation.
Recurring commitments with RRule
Commitments can be one-time or recurring. Recurring commitments use RFC 5545 RRules, the same standard used by calendar apps. A commitment like "exercise 3 times per week" is stored as FREQ=WEEKLY with a target count of 3. The RRule defines the period, the target count defines how many completions are needed within it.
The next_deadline column is maintained by a PostgreSQL trigger that evaluates the RRule. When a deadline passes, the scheduler advances it to the next period. This keeps the query for overdue commitments fast: just WHERE next_deadline < NOW() AND archived_at IS NULL.
One server, Kamal, GitHub Actions
The app runs on a single Hetzner VPS. Deploys are triggered by pushing to the production branch. GitHub Actions builds a Docker image, tags it with a deploy number, and runs kamal deploy.
Kamal handles zero-downtime deploys by starting the new container, running migrations, waiting for a health check, and then switching the proxy. SSL is automatic via Let's Encrypt. The entire deploy takes about two minutes.
There is no Kubernetes, no load balancer, no autoscaling. One server handles everything. When the app needs more capacity, it will be straightforward to add another server to the Kamal config.
Observability with OpenTelemetry and SigNoz
Every request, database query, and background job is traced with OpenTelemetry. Traces include custom attributes like chain ID, transaction hash, wallet address, and penalty amount.
An OTel Collector runs alongside the app as a Kamal accessory container. It collects application traces, parses Docker container logs, and gathers host metrics (CPU, disk, memory) and PostgreSQL metrics (locks, deadlocks, sequential scans). Everything flows to a self-hosted SigNoz instance on a separate server.
Unhandled exceptions are caught by a custom logger handler and recorded as OTel events. Oban job failures get their own error handler. The result is that every error shows up in SigNoz without explicit logging in the application code.
The logging approach follows the wide events pattern: instead of scattering narrow log lines across the codebase, each request or job emits a single structured event with all relevant context (chain, wallet, token, amount, outcome). This makes debugging straightforward because one event contains everything you need.
Error handling
The app uses a two-tier approach. Internal OTP processes (Cachex, Oban, GenServers) are allowed to crash. The BEAM restarts them, and the crash is automatically recorded in SigNoz. There are no defensive try/catch blocks around internal code.
External services (blockchain RPCs, email delivery via Resend) get explicit error handling. Failures are logged with Logger.error and return {:error, reason} tuples so callers can handle them.
This split keeps the codebase clean. Internal code trusts itself. External code handles the fact that networks are unreliable.
Testing
The test suite has over 1,300 tests with a 100% code coverage requirement enforced by a pre-commit hook. Every commit runs the full suite with coverage analysis. If a new module is not covered, the commit is blocked.
External services (blockchain RPCs, email) are mocked with Mimic. Database tests use Ecto's sandbox for isolation. Browser tests use Playwright for screenshot comparisons.
The stack, summarized
| Layer | Choice |
|---|---|
| Language | Elixir 1.19 / OTP 28 |
| Web framework | Phoenix 1.8 / LiveView 1.1 |
| Domain framework | Ash 3.0 |
| Database | PostgreSQL |
| Background jobs | Oban 2.18 |
| Blockchain | Ethers (Elixir) / Viem (JS) |
| Auth | SIWE (EIP-4361) |
| CSS | Tailwind v4 |
| Resend via Swoosh | |
| Observability | OpenTelemetry + SigNoz |
| Deployment | Kamal + GitHub Actions |
| Hosting | Single Hetzner VPS |
| Test coverage | 100% enforced |
Want to try the product?
Set a goal, put a few dollars on the line, and see how it works.
Get started