Contents

Documentation / Open Tenders

Open Tenders

Open Tenders is tender tracking and management for bid teams: one workspace for bids, deadlines, owners, and risk, replacing spreadsheet-led tracking. It is self-hosted by design: one Docker container, one SQLite file, no external services. This page is the developer documentation for deploying and understanding it.

Introduction

A bid team's pipeline usually lives in a spreadsheet one person owns and everyone else emails about. Open Tenders replaces it with a shared workspace: a dashboard of open deadlines, owned bids, and flagged risks; a kanban board that moves bids through stages from identify to won or lost; a calendar of deadlines across the team; and win-loss insights on pipeline value. A sites and staffing map plots service locations and headcounts to inform bid pricing, optional Telegram reminders push daily deadline notifications, and pipeline data exports to CSV.

The stack is deliberately small: Next.js serving both UI and API, SQLite through better-sqlite3 and Drizzle, and better-auth for accounts. No external database, no identity provider, no required API keys. The project is source-available under the PolyForm Noncommercial 1.0.0 license: free to use, modify, and share for noncommercial purposes, with commercial use licensed separately. It is not an OSI-approved open-source license.

Roles and access

Three roles gate what a user can do, checked on every request.

RoleCan do
AdminEverything: member management, invitations, organisation settings, tender deletion, full CSV export.
EditorCreate and edit tenders, deadlines, and contracts. No member management.
ViewerRead-only. Browse the pipeline, calendar, and insights. No writes.

There is no sign-up page and never will be: accounts exist only through the first-run bootstrap claim or an admin invitation. Every API handler is built by a single route() wrapper that resolves the session, reads the caller's role fresh from the database, and checks a static policy matrix before the handler runs. A request that reaches for another organisation's record gets a 404, never a 403, so the API never confirms the record exists.

Deployment

Prerequisites: Docker with Compose. Nothing else. There are no required environment variables; the whole setup story is one compose file.

# 1. Bind to the server's LAN address in docker-compose.yml
#    ports: ["192.168.1.50:3000:3000"]   <- replace with your LAN IP

# 2. Start it
docker compose up -d

# 3. Read the one-time bootstrap token from the logs
docker compose logs opentenders | grep -A3 "first-run token"

# 4. Open http://<your-lan-ip>:3000 and claim the instance with the
#    token, an admin email and password, and your organisation name

# 5. Invite the team from Settings -> Members
Note. Docker's port publishing rewrites the host's NAT rules directly and bypasses ufw and firewalld. Binding the published port to a specific LAN IP is the real exposure control, not the host firewall. Verify from outside the network you intend to expose to: an nmap scan of port 3000 from a phone on mobile data should come back filtered or closed.

Invitations are copy-a-link by default: the admin gets a single-use URL to send however they like. Configure the SMTP variables to email invitations and resets instead. For anything beyond a single trusted office LAN, put a TLS-terminating reverse proxy in front and set APP_URL to the https address and TRUST_PROXY=true.

Environment variables

Every variable is optional. The app boots with none of them set.

VariableDefaultNotes
APP_URLhttp://localhost:3000Canonical external URL. Its scheme drives the secure-cookie flag and the Origin allowlist.
DATABASE_PATH/data/opentenders.dbSQLite file location. The default is correct inside the container.
TRUST_PROXYunsetSet true only behind a reverse proxy you control. Gates whether X-Forwarded-* headers are trusted; on a bare LAN they are attacker-spoofable and ignored.
AUTH_SECRETgeneratedCreated on first boot and persisted to /data/.auth-secret at mode 0600. Leave unset.
SMTP_HOST (+ SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM)unsetOptional email for invitations and resets. Copy-a-link otherwise.
TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME)unsetEnables daily deadline reminders over long polling. Off without it.

Architecture

One Next.js process serves the UI and the API against one SQLite file opened in WAL mode. There is no external service in the default install: better-auth provides email-and-password accounts with server-side cookie sessions, an in-process scheduler runs the daily deadline reminders, and the Telegram integration uses outbound long polling, so it works behind NAT with no webhook.

Every API handler is defined through the route() factory, and route() is the only way to define one. In order, it performs an Origin check on state-changing verbs, rate limiting, session resolution, a fresh membership and role read, and a fail-closed policy-matrix lookup, then hands the handler an organisation-scoped database facade that injects the organisation id into every query, so an unscoped query is not expressible from handler code. A CI test enumerates every route file and fails if a handler bypasses the wrapper or quietly declares itself public.

Money is stored as integer pence and crosses the API as pounds. The conversion lives in one mapper module, so neither the UI nor the schema can silently lose pennies.

Security model

Invitations use single-use bearer tokens: 32 random bytes, stored only as a SHA-256 hash, expiring after seven days, revocable by an admin, and redeemed inside one transaction so a token cannot be accepted twice. Sessions are server-side rows, not JWTs, so revoking a session actually revokes it; removing a member disables the account and drops its sessions in the same transaction. Sign-in is rate limited with a temporary, capped lockout, and a locked-out sole admin recovers through the same bootstrap-token mechanism used at install, never by editing the database by hand.

The container runs as a non-root user with all capabilities dropped and a read-only filesystem. The /data volume is a credential store, not just the database: it holds password hashes, session tokens, hashed invitation tokens, and the auth-signing secret, all written at mode 0600.

Backups and recovery

Backing up is copying one file. A WAL-safe backup runs while the container is up:

docker compose exec opentenders node -e "
  const Database = require('better-sqlite3');
  new Database(process.env.DATABASE_PATH).backup('/tmp/backup.db').then(() => process.exit(0));
"
docker compose cp opentenders:/tmp/backup.db ./opentenders-backup-$(date +%F).db

Restore is the whole rollback story: stop the container, replace /data/opentenders.db with the backup, restart. Migrations are forward-only and transactional; a failed migration refuses to start rather than serving a half-migrated database. Treat backup copies like a password vault: the file contains password hashes and, until they expire, replayable session tokens.