SigNoteSigNote
Documentation

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.yml is 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:

VariableDescriptionRequired
NEXTAUTH_URLExact public origin β€” must match what you use in the browserYes
NEXTAUTH_SECRETSecret used by NextAuth to sign session tokens (make it long and random)Yes
DATABASE_URLPostgres connection stringYes
PG_POOL_SIZEMax pooled connections (default 10)No

SIWE (Sign-In with Ethereum):

VariableDescriptionRequired
NEXT_PUBLIC_WALLETCONNECT_PROJECT_IDWalletConnect project ID from cloud.walletconnect.comYes
NEXT_PUBLIC_RPC_URLEthereum RPC endpoint (Infura, Alchemy, or public)Yes

Google OAuth (optional):

VariableDescriptionRequired
GOOGLE_CLIENT_IDOAuth 2.0 Client ID from Google CloudNo*
GOOGLE_CLIENT_SECRETOAuth 2.0 Client Secret from Google CloudNo*

*Only required if you want to enable Google OAuth sign-in. SIWE works without Google credentials.

S3 file storage:

VariableDescriptionRequired
AWS_S3_BUCKETS3 bucket name where files are storedYes†
AWS_S3_REGIONAWS region (e.g. us-east-1)Yes†
AWS_ACCESS_KEY_IDAWS access key IDYes†
AWS_SECRET_ACCESS_KEYAWS secret access keyYes†
AWS_S3_ENDPOINTCustom endpoint URL β€” set this to use an S3-compatible provider like Cloudflare R2No

†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: * on PutObject, 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 single PutObject.
  • Entity tags on GetObject and HeadObject, 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/HEAD and the if-none-match and content-type headers, and exposing ETag.

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:

VariableDescriptionRequired
CRON_SECRETSecret used to authenticate the cleanup cron job (/api/service/storage), which runs dailyNo

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:

VariableDescriptionRequired
ROTATION_DISABLEDSet to 1 to stop new key changes from being started. Anything else, or unset, allows themNo
VAULT_EXPORT_DISABLEDSet to 1 to hide and refuse new portable vault exports. Existing export streams remain readable/cancellable until they expire. Import stays availableNo

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:

  1. Go to Google Cloud Console
  2. Create a new project (or select an existing one)
  3. Enable the Google+ API
  4. Create an OAuth 2.0 credential (type: Web application)
  5. Add authorized redirect URIs:
    • For local dev: http://localhost:5000/api/auth/callback/google
    • For production: https://yourdomain.com/api/auth/callback/google
  6. Copy the Client ID and Client Secret into .env.local

⚠️ NEXTAUTH_URL is 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, use http://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

  1. 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)
  2. Once signed in, navigate to Secrets or Seals to set up encryption
  3. Choose a strong passphrase (minimum 8 characters) and save your encryption profile
  4. 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.