Self-hosting Guide π
This guide walks you through running SigNote locally for development or self-hosting.
Prerequisites
- Node.js 18 or later
- PostgreSQL 14 or later β a local instance (a
docker-compose.ymlis included) or a hosted one such as Supabase or Neon - WalletConnect project ID β get one free at cloud.walletconnect.com (required for SIWE)
- Ethereum wallet β MetaMask, Coinbase Wallet, or any wallet supported by RainbowKit (required for SIWE)
- An Ethereum RPC URL β Infura, Alchemy, or a public endpoint (required for SIWE)
- Google OAuth credentials (optional) β set up at Google Cloud Console if you want to support Google sign-in
1. Clone and install
git clone https://github.com/irondsd/signote.git
cd signote
bun install
2. Configure environment variables
Copy the example file:
cp .env.local.example .env.local
Then fill in .env.local:
# Required for all deployments
NEXTAUTH_URL="http://localhost:5000"
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
DATABASE_URL="postgres://<user>:<password>@<host>:5432/<database>"
# Required for SIWE (Sign-In with Ethereum)
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=""
NEXT_PUBLIC_RPC_URL=""
# Optional: for Google OAuth support
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# S3 file storage
AWS_S3_BUCKET=""
AWS_S3_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_S3_ENDPOINT="" # Optional: for S3-compatible providers (e.g. Cloudflare R2)
# Cron job secret
CRON_SECRET=""
Core variables:
| Variable | Description | Required |
|---|---|---|
NEXTAUTH_URL | Exact public origin β must match what you use in the browser | Yes |
NEXTAUTH_SECRET | Secret used by NextAuth to sign session tokens (make it long and random) | Yes |
DATABASE_URL | Postgres connection string | Yes |
PG_POOL_SIZE | Max pooled connections (default 10) | No |
SIWE (Sign-In with Ethereum):
| Variable | Description | Required |
|---|---|---|
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID | WalletConnect project ID from cloud.walletconnect.com | Yes |
NEXT_PUBLIC_RPC_URL | Ethereum RPC endpoint (Infura, Alchemy, or public) | Yes |
Google OAuth (optional):
| Variable | Description | Required |
|---|---|---|
GOOGLE_CLIENT_ID | OAuth 2.0 Client ID from Google Cloud | No* |
GOOGLE_CLIENT_SECRET | OAuth 2.0 Client Secret from Google Cloud | No* |
*Only required if you want to enable Google OAuth sign-in. SIWE works without Google credentials.
S3 file storage:
| Variable | Description | Required |
|---|---|---|
AWS_S3_BUCKET | S3 bucket name where files are stored | Yesβ |
AWS_S3_REGION | AWS region (e.g. us-east-1) | Yesβ |
AWS_ACCESS_KEY_ID | AWS access key ID | Yesβ |
AWS_SECRET_ACCESS_KEY | AWS secret access key | Yesβ |
AWS_S3_ENDPOINT | Custom endpoint URL β set this to use an S3-compatible provider like Cloudflare R2 | No |
β Required to enable file attachments. Without these, file uploads will fail.
What key rotation asks of your storage provider. Attachments alone are undemanding; re-keying them is not. Changing your encryption keys re-encrypts every attachment in the browser and writes the replacement straight to the bucket over a presigned URL, so a provider that only serves plain uploads is not enough. It must support:
- Conditional create β
If-None-Match: *onPutObject, so a stale grant cannot overwrite an object that has already been accepted. - Signed
Content-Length, so a grant accepts only a body of the exact length it was issued for. No provider checksum is asked for: the server reads every accepted object back in full and hashes it itself, because Cloudflare R2 has no full-object SHA-256 for a singlePutObject. - Entity tags on
GetObjectandHeadObject, which is how the server re-identifies an already-verified object at commit without downloading it again. - Browser CORS for your app's origin, permitting
GET/PUT/HEADand theif-none-matchandcontent-typeheaders, and exposingETag.
Cloudflare R2 and MinIO both qualify; so does S3 itself. If you set
AWS_S3_ENDPOINT, set it in the build environment as well as at runtime β
the app's Content-Security-Policy names that origin in connect-src, and the
policy is baked in at build time. Without it the browser blocks every transfer
with nothing to see in the server logs. A production build prints the origin it
resolved ([csp] connect-src storage origin: β¦); check for that line.
Cron job:
| Variable | Description | Required |
|---|---|---|
CRON_SECRET | Secret used to authenticate the cleanup cron job (/api/service/storage), which runs daily | No |
That job is what reclaims the obsolete objects a key change leaves behind, and what releases the temporary storage it reserved. Without it a completed rotation keeps paying for both copies.
/api/service/rotation does the rotation half on its own, authenticated the
same way. It is not scheduled by default β the daily job already calls it β but
if your host lets you schedule more finely, pointing an hourly job at it returns
reclaimed storage sooner. Nothing depends on it: starting a key change reclaims
that account's own abandoned objects first, so a retry is never blocked waiting
for a sweep.
Operation feature gates:
| Variable | Description | Required |
|---|---|---|
ROTATION_DISABLED | Set to 1 to stop new key changes from being started. Anything else, or unset, allows them | No |
VAULT_EXPORT_DISABLED | Set to 1 to hide and refuse new portable vault exports. Existing export streams remain readable/cancellable until they expire. Import stays available | No |
Both variables stop only new operations. An existing export remains readable or cancellable until its short expiry. An existing rotation can still be resumed, cancelled, and committed, because a half-finished key change holds the vault frozen until it is resolved.
Setting up Google OAuth (optional)
If you want to support Google sign-in, follow these steps:
- Go to Google Cloud Console
- Create a new project (or select an existing one)
- Enable the Google+ API
- Create an OAuth 2.0 credential (type: Web application)
- Add authorized redirect URIs:
- For local dev:
http://localhost:5000/api/auth/callback/google - For production:
https://yourdomain.com/api/auth/callback/google
- For local dev:
- Copy the Client ID and Client Secret into
.env.local
β οΈ
NEXTAUTH_URLis used by both SIWE and Google OAuth to validate the origin. It must exactly match the URL you use in the browser. For local dev, usehttp://localhost:5000.
3. Set up the database
If you don't have a Postgres already, the repo ships one:
bun run local:up # Development Postgres on :5434 and MinIO on :9100
Then point DATABASE_URL at it and create the schema:
DATABASE_URL="postgres://signote:signote@localhost:5434/signote" # in .env.local
bun run db:push
The schema is defined in src/db/schema.ts and synced straight into the database with db:push β there are no migration files, and nothing is applied automatically at boot. Re-run db:push after pulling schema changes; read the statements it prints before confirming, since a rename or type change can drop data.
Every db:* command targets the database in .env.local and prints the host it resolved before doing anything. The :prod variants (db:push:prod, db:studio:prod) read a separate, gitignored .env.prod instead β so hitting production is always something you typed on purpose.
Upgrading a database created before per-account record ids. Record ids used to be unique across the whole deployment; they are now unique per account, so a restored vault archive can keep its ids. db:push cannot make that change on a database that already holds data (it would have to invent a user_id for every history and tag row), so run the one-off migration first, then deploy the new version, then db:push:
bun run db:migrate:ids --dry-run # runs it all against your data, then rolls back
bun run db:migrate:ids # or db:migrate:ids:prod for .env.prod
It runs in one transaction, is safe to re-run, and a fresh database never needs it. The primary-key swap takes exclusive table locks; the 10-second lock timeout limits how long it waits to acquire them, not how long the migration runs afterward. Drill it against a recent production-sized copy, take a database backup, put the application into maintenance/read-only mode, and deploy the matching application version immediately afterward. Run ANALYZE after a production migration. Between the migration and the deploy, the old version cannot save note history or tags.
Once two accounts contain the same restored record ids, the old globally unique schema is no longer a direct rollback target. Restoring the pre-migration database backup also discards every write made afterward, so decide and rehearse the rollback window before enabling Import.
4. Run the dev server
bun run dev
Open http://localhost:5000 in your browser.
5. Sign in and set up encryption
- Click Sign in and choose your authentication method:
- Sign in with Ethereum β connect your wallet and sign a SIWE message
- Sign in with Google β use your Google account (if configured)
- Once signed in, navigate to Secrets or Seals to set up encryption
- Choose a strong passphrase (minimum 8 characters) and save your encryption profile
- Optional: Link additional authentication methods in your profile settings
Running tests
Unit tests (Jest) run against an in-process Postgres (PGlite) with the real schema applied β no setup needed:
bun run test # Run all unit tests
End-to-end tests (Playwright) start their own native PostgreSQL process and dev server. Prepare the binaries explicitly once; Docker and a preinstalled database are not required.
bun run test:e2e:prepare # Download PostgreSQL + Chromium
bun run test:e2e # Run all E2E tests
npx playwright test tests/specs/notes.spec.ts # Run one file
npx playwright test --ui # Run with Playwright UI
The prepare command disables install scripts and explicitly restores PostgreSQL binary symlinks. Re-run it after upgrading Playwright or the pinned PostgreSQL version. On Linux CI, add --with-deps to install browser system libraries and run the suite as a non-root user.
Each run creates a fresh temporary database on an available loopback port, pushes the real schema, and shares it across parallel workers. Incoming DATABASE_URL and TEST_DATABASE_URL values are ignored. Teardown stops the owned processes and removes test data, including after setup failures. The app still reads its test settings from .env.test.
Other useful commands:
bun run lint # ESLint + TypeScript type check
bun run format # Prettier formatting
bun run build # Production build
Project structure
src/
βββ app/ # Next.js App Router pages and API routes
β βββ api/
β β βββ encryption/ # Encryption profile and key material endpoints
β β βββ files/ # File upload and retrieval
β β βββ notes/ # Tier 1 CRUD
β β βββ secrets/ # Tier 2 CRUD
β β βββ seals/ # Tier 3 CRUD
β βββ secrets/ # Tier 2 pages
β βββ seals/ # Tier 3 pages
βββ components/ # UI and feature components
βββ config/ # Auth, wallet, and server configuration
βββ contexts/ # EncryptionContext β MEK lifecycle management
βββ controllers/ # Application logic over the database layer
βββ db/ # Drizzle: schema.ts, client.ts, tier.ts (shared tier repo)
βββ hooks/ # TanStack Query data and mutation hooks
βββ lib/
β βββ crypto.ts # All client-side cryptographic operations
βββ providers/ # App-wide React providers
Contributing
SigNote is open source. If you find a bug, have an idea, or want to contribute, head over to GitHub:
π github.com/irondsd/signote
Issues and pull requests are welcome.
β Want to understand the app? Start with About.