docs(self-hosting): describe the SQLite/Base storage this actually uses

The self-hosting docs still described PostgreSQL — connection strings, database
pricing, "plan for database growth". esign runs on embedded SQLite (Hanzo Base),
a single sign.db file on a persistent volume, so every one of those instructions
sent a self-hoster to provision infrastructure the app never touches.

Rewrites configuration, deployment, getting-started and maintenance to match:
documents stored base64 in sign.db rather than in Postgres, capacity planning
against the volume holding that file rather than database pricing, and backups
of the file rather than pg_dump.

Docs only — no behaviour change.
This commit is contained in:
2026-07-26 02:07:33 -07:00
parent ed60498e38
commit aad4fbc516
12 changed files with 459 additions and 482 deletions
@@ -13,7 +13,8 @@ These variables must be set for Hanzo eSign to function:
| `NEXT_PRIVATE_ENCRYPTION_KEY` | Primary encryption key for symmetric encryption (minimum 32 characters) |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | Secondary encryption key for symmetric encryption (minimum 32 characters) |
| `NEXT_PUBLIC_WEBAPP_URL` | Public URL of your Hanzo eSign instance (e.g., `https://sign.example.com`) |
| `NEXT_PRIVATE_DATABASE_URL` | PostgreSQL connection URL with connection pooling |
| `DATABASE_URL` | SQLite database file URL (e.g., `file:/opt/hanzo-sign/base/data/_dev/sign.db`) |
| `BASE_PATH` | Directory root for embedded Base data on the persistent disk |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | Email address used as the sender for all outgoing emails |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | Display name for the sender of outgoing emails |
@@ -30,19 +31,26 @@ These variables must be set for Hanzo eSign to function:
## Database Configuration
Hanzo eSign requires PostgreSQL 14 or higher.
Hanzo eSign stores all of its data in an **embedded SQLite database (Hanzo Base)**
on the application's persistent disk. There is no external database server to
provision, connect, or tune. esign uses a single shared SQLite file with tenancy
enforced at the query layer (see HIP-0305), not a database-per-tenant split.
| Variable | Required | Description |
| ---------------------------------- | ------------ | ------------------------------------------------------------------------------------------ |
| `NEXT_PRIVATE_DATABASE_URL` | Yes | PostgreSQL connection URL. Supports connection pooling (e.g., PgBouncer) |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | When pooling | Direct PostgreSQL URL for migrations. Defaults to `NEXT_PRIVATE_DATABASE_URL` when not set |
| Variable | Required | Description |
| -------------- | -------- | -------------------------------------------------------------------------- |
| `BASE_PATH` | Yes | Directory root for Base data on the persistent disk |
| `DATABASE_URL` | Yes | SQLite file URL. Defaults to a file under `BASE_PATH` |
**Connection string format:**
```
postgres://user:password@host:port/database
file:/opt/hanzo-sign/base/data/_dev/sign.db
```
The `file:` URL is the only form SQLite accepts. The application appends
`connection_limit=1` automatically — SQLite serialises writes, so a single
writer is correct and a larger pool only produces `SQLITE_BUSY`.
For detailed database setup, see [Database Configuration](/docs/self-hosting/configuration/database).
---
@@ -148,7 +156,7 @@ For detailed email setup, see [Email Configuration](/docs/self-hosting/configura
## Storage Configuration
Hanzo eSign can store documents in the database or S3-compatible storage.
Hanzo eSign can store documents in the embedded SQLite database (Hanzo Base) or in S3-compatible storage.
| Variable | Description | Default |
| ---------------------------------------- | ------------------------------------------- | ---------- |
@@ -248,11 +256,11 @@ AI features must also be enabled in organisation/team settings after configurati
## Background Jobs
Hanzo eSign uses a PostgreSQL-based job queue by default. Jobs (email delivery, document processing, webhook dispatch) are stored in the `BackgroundJob` table and processed via internal HTTP requests. No external queue service like Redis is required.
Hanzo eSign uses a SQLite-backed job queue by default. Jobs (email delivery, document processing, webhook dispatch) are stored in the `BackgroundJob` table inside the embedded Base database and processed via internal HTTP requests. No external queue service like Redis is required.
| Variable | Description | Default |
| ---------------------------- | ------------------------------------------------------------------------------ | ------- |
| `NEXT_PRIVATE_JOBS_PROVIDER` | Jobs provider: `local` (PostgreSQL-based queue) or `inngest` (managed service) | `local` |
| Variable | Description | Default |
| ---------------------------- | -------------------------------------------------------------------------- | ------- |
| `NEXT_PRIVATE_JOBS_PROVIDER` | Jobs provider: `local` (SQLite-backed queue) or `inngest` (managed service) | `local` |
### Inngest Configuration
@@ -311,9 +319,9 @@ NEXT_PRIVATE_ENCRYPTION_KEY="your-encryption-key-at-least-32-chars"
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="your-secondary-key-at-least-32-chars"
NEXT_PUBLIC_WEBAPP_URL="https://sign.example.com"
# Database
NEXT_PRIVATE_DATABASE_URL="postgres://user:password@localhost:5432/hanzo-sign"
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://user:password@localhost:5432/hanzo-sign"
# Database (embedded Base SQLite on a persistent disk)
BASE_PATH="/opt/hanzo-sign/base/data"
DATABASE_URL="file:/opt/hanzo-sign/base/data/_dev/sign.db"
# Email
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
@@ -332,7 +340,7 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
## See Also
- [Database Configuration](/docs/self-hosting/configuration/database) - Set up PostgreSQL
- [Database Configuration](/docs/self-hosting/configuration/database) - Set up the embedded SQLite database
- [Email Configuration](/docs/self-hosting/configuration/email) - Configure email delivery
- [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Configure document signing
@@ -11,7 +11,7 @@ description: Configure your self-hosted Hanzo eSign instance with environment va
/>
<Card
title="Database"
description="PostgreSQL setup and configuration."
description="Embedded SQLite (Hanzo Base) setup."
href="/docs/self-hosting/configuration/database"
/>
<Card
@@ -35,7 +35,7 @@ description: Configure your self-hosted Hanzo eSign instance with environment va
At minimum, you need to configure:
1. **Database** - PostgreSQL connection
1. **Database** - embedded SQLite file path (`DATABASE_URL` / `BASE_PATH`)
2. **Email** - SMTP for sending notifications
3. **Base URL** - Your instance's public URL
@@ -29,21 +29,21 @@ NEXT_PUBLIC_UPLOAD_TRANSPORT=s3
## Database Storage
Database storage is the default option and requires no additional configuration. Documents are stored as base64-encoded data directly in PostgreSQL.
Database storage is the default option and requires no additional configuration. Documents are stored as base64-encoded data directly in the embedded SQLite database (Hanzo Base), in the same `sign.db` file as the rest of your data.
<Tabs items={['Advantages', 'Limitations']}>
<Tab value="Advantages">
- No external dependencies
- Simple deployment
- Automatic backups with database
- Backed up together with the rest of the database (single file snapshot)
</Tab>
<Tab value="Limitations">
- Increases database size significantly
- Grows the `sign.db` file significantly
- Slower for large files
- Database backup/restore takes longer
- File snapshots take longer as the database grows
- Not recommended for files larger than 10MB
</Tab>
@@ -431,7 +431,8 @@ New uploads use the configured `NEXT_PUBLIC_UPLOAD_TRANSPORT` backend.
### Database Storage Estimates
When using database storage, plan for significant database growth:
When using database storage, plan for significant growth of the `sign.db` file
on your persistent volume:
| Documents/Month | Avg Size | Monthly Growth | Annual Growth |
| --------------- | -------- | -------------- | ------------- |
@@ -455,12 +456,17 @@ S3 stores files without encoding overhead:
For high-volume deployments, S3 is more cost-effective:
| Aspect | Database Storage | S3 Storage |
| ------------- | ---------------------------- | ----------------------- |
| Storage cost | Database pricing (~$0.10/GB) | S3 pricing (~$0.023/GB) |
| Transfer cost | Database I/O | S3 requests + egress |
| Backup cost | Larger database backups | Separate S3 backups |
| Performance | Degrades with size | Consistent |
| Aspect | Database Storage | S3 Storage |
| ------------- | -------------------------------------- | ----------------------- |
| Storage cost | Persistent volume holding `sign.db` | S3 pricing (~$0.023/GB) |
| Transfer cost | Local disk I/O | S3 requests + egress |
| Backup cost | Larger `sign.db` snapshots | Separate S3 backups |
| Performance | Degrades as the file grows | Consistent |
With database storage there is no managed-database bill: the documents live in
the same `sign.db` file on the persistent volume you already pay for. The
tradeoff is that the volume must grow with your document corpus, and snapshots
take longer.
---
@@ -563,7 +569,7 @@ Default limit is 50MB per request.
## See Also
- [Database Configuration](/docs/self-hosting/configuration/database) - Configure PostgreSQL
- [Database Configuration](/docs/self-hosting/configuration/database) - Configure the SQLite database
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
- [Backups](/docs/self-hosting/maintenance/backups) - Backup strategies for both storage backends
- [Docker Compose](/docs/self-hosting/deployment/docker-compose) - Deploy with MinIO for local storage
@@ -1,6 +1,6 @@
---
title: Docker Compose
description: Deploy Hanzo eSign with Docker Compose, including PostgreSQL. This production-ready setup is suitable for most self-hosted deployments.
description: Deploy Hanzo eSign with Docker Compose. A single application container with an embedded SQLite database on a named volume — production-ready for most self-hosted deployments.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
@@ -68,48 +68,42 @@ Add the required configuration (see [Environment Configuration](#environment-con
## Docker Compose File Overview
The production `compose.yml` includes two services:
esign uses an embedded SQLite database (Hanzo Base) — a single shared `sign.db`
file with tenancy enforced at the query layer (see HIP-0305), not a separate
database server. The production `compose.yml` is therefore a single service whose
data lives on a named volume mounted at `BASE_PATH`:
```yaml
name: hanzo-sign-production
services:
database:
image: postgres:15
environment:
- POSTGRES_USER=${POSTGRES_USER:?err}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
- POSTGRES_DB=${POSTGRES_DB:?err}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
interval: 10s
timeout: 5s
retries: 5
volumes:
- database:/var/lib/postgresql/data
hanzo-sign:
image: hanzo-sign/hanzo-sign:latest
depends_on:
database:
condition: service_healthy
environment:
- BASE_PATH=/opt/hanzo-sign/base/data
- DATABASE_URL=file:/opt/hanzo-sign/base/data/_dev/sign.db
# See environment configuration below
ports:
- ${PORT:-3000}:${PORT:-3000}
volumes:
- base-data:/opt/hanzo-sign/base/data
- /opt/hanzo-sign/cert.p12:/opt/hanzo-sign/cert.p12:ro
volumes:
database:
base-data:
```
| Service | Purpose |
| ----------- | ------------------------------------------------------------ |
| `database` | PostgreSQL 15 database with persistent storage |
| `hanzo-sign` | Main application container, waits for database to be healthy |
| Service | Purpose |
| ------------ | ----------------------------------------------------------------------- |
| `hanzo-sign` | Main application container; its SQLite database lives on the `base-data` volume |
The Hanzo eSign container waits for the database health check to pass before starting.
There is no separate database container to wait for — the database is a file on
the `base-data` volume, created and migrated automatically on first start.
<Callout type="warn">
The `base-data` volume must be persistent. If you point `BASE_PATH` at
ephemeral container storage, all data is lost when the container is recreated.
</Callout>
## Environment Configuration
@@ -118,10 +112,9 @@ Create a `.env` file with the following variables:
### Required Variables
```bash
# Database (used by both database and hanzo-sign services)
POSTGRES_USER=hanzo-sign
POSTGRES_PASSWORD=your-secure-database-password
POSTGRES_DB=hanzo-sign
# Database (embedded Base SQLite on the base-data volume)
BASE_PATH=/opt/hanzo-sign/base/data
DATABASE_URL=file:/opt/hanzo-sign/base/data/_dev/sign.db
# Application secrets (generate with: openssl rand -base64 32)
NEXT_PRIVATE_AUTH_SECRET=your-nextauth-secret
@@ -132,9 +125,6 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=your-secondary-key-min-32-characters
NEXT_PUBLIC_WEBAPP_URL=https://sign.example.com
NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
# Database connection (uses Docker service name)
NEXT_PRIVATE_DATABASE_URL=postgresql://hanzo-sign:your-secure-database-password@database:5432/hanzo-sign
# Email configuration
NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
NEXT_PRIVATE_SMTP_HOST=smtp.example.com
@@ -173,11 +163,11 @@ echo "NEXT_PRIVATE_AUTH_SECRET=$(openssl rand -base64 32)"
# Generate encryption keys
echo "NEXT_PRIVATE_ENCRYPTION_KEY=$(openssl rand -base64 32)"
echo "NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=$(openssl rand -base64 32)"
# Generate database password
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)"
```
The embedded SQLite database has no separate user or password to generate — there
is no database server to authenticate against.
## Signing Certificate
A signing certificate is required for document signing. Generate a `.p12` certificate on your host machine and mount it into the container. See [Local Certificate](/docs/self-hosting/configuration/signing-certificate/local) for how to generate one.
@@ -224,12 +214,11 @@ docker compose ps
Expected output:
```
NAME STATUS PORTS
hanzo-sign-production-database-1 running (healthy) 5432/tcp
hanzo-sign-production-hanzo-sign-1 running 0.0.0.0:3000->3000/tcp
NAME STATUS PORTS
hanzo-sign-production-hanzo-sign-1 running 0.0.0.0:3000->3000/tcp
```
Wait for the database to be healthy and for migrations to complete. Check the logs:
Wait for the database file to be created and migrations to complete on first start. Check the logs:
```bash
docker compose logs -f hanzo-sign
@@ -264,11 +253,10 @@ View all service logs:
docker compose logs -f
```
View logs for a specific service:
View logs for the application service:
```bash
docker compose logs -f hanzo-sign
docker compose logs -f database
```
### Restart Services
@@ -306,7 +294,8 @@ docker compose down -v
```
<Callout type="error">
Using `down -v` deletes the database volume. Back up your data first.
Using `down -v` deletes the `base-data` volume — and with it the entire
`sign.db` database. Back up your data first.
</Callout>
### Update Hanzo eSign
@@ -377,14 +366,20 @@ sign.example.com {
### Database Backups
Set up automated database backups:
The database is a single SQLite file on the `base-data` volume, so backups are
file snapshots — no `pg_dump`, no replication topology. Use `sqlite3 .backup`
for a consistent copy even while the app is running:
```bash
# Manual backup
docker compose exec database pg_dump -U hanzo-sign hanzo-sign > backup.sql
# Manual backup (consistent online copy, includes the WAL)
docker compose exec hanzo-sign \
sqlite3 /opt/hanzo-sign/base/data/_dev/sign.db ".backup '/opt/hanzo-sign/base/data/sign-backup.db'"
docker compose cp hanzo-sign:/opt/hanzo-sign/base/data/sign-backup.db ./sign-backup.db
# Restore from backup
docker compose exec -T database psql -U hanzo-sign hanzo-sign < backup.sql
# Restore: stop the app, replace the file, restart
docker compose stop hanzo-sign
docker compose cp ./sign-backup.db hanzo-sign:/opt/hanzo-sign/base/data/_dev/sign.db
docker compose start hanzo-sign
```
See [Backups](/docs/self-hosting/maintenance/backups) for automated backup strategies.
@@ -406,25 +401,19 @@ services:
memory: 512M
```
### External Database
### No External Datastore Needed
For production, consider using a managed PostgreSQL service:
esign's database is embedded — there is no managed PostgreSQL service to attach,
no connection string to rotate, and no separate datastore to scale. Production
durability comes from putting `BASE_PATH` (the `base-data` volume) on reliable,
backed-up block storage and snapshotting it on a schedule. See
[Backups](/docs/self-hosting/maintenance/backups) for volume-snapshot strategies.
{/* prettier-ignore */}
<Steps>
<Step>
Remove the `database` service from `compose.yml`
</Step>
<Step>
Update environment variables:
```bash
NEXT_PRIVATE_DATABASE_URL=postgresql://user:password@your-db-host:5432/hanzo-sign
NEXT_PRIVATE_DIRECT_DATABASE_URL=postgresql://user:password@your-db-host:5432/hanzo-sign
```
</Step>
</Steps>
<Callout type="warn">
SQLite is a single-writer database. Run exactly one `hanzo-sign` container that
owns the `base-data` volume. Do not scale this service to multiple replicas
pointed at the same file.
</Callout>
### S3 Storage
@@ -445,12 +434,15 @@ See [Storage Configuration](/docs/self-hosting/configuration/storage).
<Accordions type="multiple">
<Accordion title="Container fails to start">
Check logs: `docker compose logs hanzo-sign`. Common causes: missing environment variables
(ensure all required variables are in `.env`), database not ready (the container waits for
database health check), port conflict (change `PORT` in `.env` if 3000 is in use).
(ensure all required variables, including `BASE_PATH` and `DATABASE_URL`, are in `.env`),
the `base-data` volume not writable by the app user (UID 1001), port conflict (change `PORT`
in `.env` if 3000 is in use).
</Accordion>
<Accordion title="Database connection errors">
Verify the database is healthy: `docker compose ps database`. Test: `docker compose exec
database psql -U hanzo-sign -d hanzo-sign -c "SELECT 1"`.
<Accordion title="unable to open database file">
The directory in `DATABASE_URL` does not exist or is not writable. Confirm the `base-data`
volume is mounted at `BASE_PATH` and the path is absolute:
`docker compose exec hanzo-sign ls -la /opt/hanzo-sign/base/data`. Then verify the database:
`docker compose exec hanzo-sign sqlite3 /opt/hanzo-sign/base/data/_dev/sign.db 'SELECT 1'`.
</Accordion>
<Accordion title="Certificate errors">
Check certificate status: `curl http://localhost:3000/api/certificate-status`. Common issues:
@@ -1,6 +1,6 @@
---
title: Docker
description: Run Hanzo eSign as a standalone Docker container with an external database. Ideal for container platforms like AWS ECS, Google Cloud Run, or Azure Container Instances.
description: Run Hanzo eSign as a standalone Docker container with an embedded SQLite database on a persistent volume. Ideal for container platforms like AWS ECS, Google Cloud Run, or Azure Container Instances.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
@@ -9,12 +9,12 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
## Prerequisites
For a self-contained setup that includes PostgreSQL, see [Docker Compose Deployment](/docs/self-hosting/deployment/docker-compose).
For a self-contained setup with a named volume managed for you, see [Docker Compose Deployment](/docs/self-hosting/deployment/docker-compose).
Before starting, ensure you have:
- [Docker](https://docs.docker.com/get-docker/) 20.10 or later installed
- A PostgreSQL 14+ database accessible from your Docker host
- A persistent volume to mount at `BASE_PATH` for the SQLite database
- SMTP credentials for sending emails
- At least 1GB of available RAM
@@ -55,27 +55,31 @@ These variables must be set for Hanzo eSign to start:
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | Secondary encryption key (minimum 32 characters) | - |
| `NEXT_PUBLIC_WEBAPP_URL` | Public URL where Hanzo eSign is accessible (e.g., `https://sign.example.com`) | - |
| `NEXT_PRIVATE_INTERNAL_WEBAPP_URL` | Internal URL the app uses to call itself (for background jobs) | Same as `NEXT_PUBLIC_WEBAPP_URL` |
| `NEXT_PRIVATE_DATABASE_URL` | PostgreSQL connection string | - |
| `BASE_PATH` | Directory root for Base data on the mounted persistent volume | - |
| `DATABASE_URL` | SQLite file URL (e.g., `file:/opt/hanzo-sign/base/data/_dev/sign.db`) | - |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | Email transport: `smtp-auth`, `smtp-api`, `resend`, or `mailchannels` | `smtp-auth` |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | Sender name for outgoing emails | - |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | Sender email address | - |
### Database Connection
Hanzo eSign requires two database connection strings:
Hanzo eSign stores all data in an embedded SQLite database (Hanzo Base) on the
mounted volume. Two variables control where it lives:
| Variable | Required | Default | Description |
| ---------------------------------- | ------------ | --------------------------- | --------------------------------- |
| `NEXT_PRIVATE_DATABASE_URL` | Yes | - | Pooled connection for app queries |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | When pooling | `NEXT_PRIVATE_DATABASE_URL` | Direct connection for migrations |
| Variable | Required | Default | Description |
| -------------- | -------- | ------- | --------------------------------------------------- |
| `BASE_PATH` | Yes | - | Directory root for Base data on the persistent volume |
| `DATABASE_URL` | Yes | - | SQLite `file:` URL, typically under `BASE_PATH` |
Connection string format:
```
postgresql://user:password@host:5432/database
file:/opt/hanzo-sign/base/data/_dev/sign.db
```
If you're not using a connection pooler like PgBouncer, both variables can use the same connection string.
The `file:` URL is the only form SQLite accepts. The application appends
`connection_limit=1` automatically — SQLite serialises writes, so a single
writer is correct.
### SMTP Configuration
@@ -112,13 +116,15 @@ For the complete list, see [Environment Variables](/docs/self-hosting/configurat
docker run -d \
--name hanzo-sign \
-p 3000:3000 \
-v hanzo-sign-base:/opt/hanzo-sign/base/data \
-v /path/to/cert.p12:/opt/hanzo-sign/cert.p12:ro \
-e NEXT_PRIVATE_AUTH_SECRET="$(openssl rand -base64 32)" \
-e NEXT_PRIVATE_ENCRYPTION_KEY="your-encryption-key-min-32-chars" \
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="your-secondary-key-min-32-chars" \
-e NEXT_PUBLIC_WEBAPP_URL="https://sign.example.com" \
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@db-host:5432/hanzo-sign" \
-e BASE_PATH="/opt/hanzo-sign/base/data" \
-e DATABASE_URL="file:/opt/hanzo-sign/base/data/_dev/sign.db" \
-e NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" \
-e NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" \
-e NEXT_PRIVATE_SMTP_HOST="smtp.example.com" \
@@ -130,6 +136,13 @@ docker run -d \
hanzo-sign/hanzo-sign:latest
```
<Callout type="warn">
The `-v hanzo-sign-base:/opt/hanzo-sign/base/data` mount is required. The SQLite
database lives under `BASE_PATH`; without a persistent volume there, all data is
lost when the container is recreated. SQLite is single-writer, so run exactly one
container that owns this volume.
</Callout>
### Signing Certificate
A signing certificate is required for document signing. You have two options for providing one:
@@ -159,8 +172,8 @@ NEXT_PRIVATE_ENCRYPTION_KEY=your-encryption-key-min-32-chars
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=your-secondary-key-min-32-chars
NEXT_PUBLIC_WEBAPP_URL=https://sign.example.com
NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
NEXT_PRIVATE_DATABASE_URL=postgresql://user:password@db-host:5432/hanzo-sign
NEXT_PRIVATE_DIRECT_DATABASE_URL=postgresql://user:password@db-host:5432/hanzo-sign
BASE_PATH=/opt/hanzo-sign/base/data
DATABASE_URL=file:/opt/hanzo-sign/base/data/_dev/sign.db
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
NEXT_PRIVATE_SMTP_HOST=smtp.example.com
@@ -178,6 +191,7 @@ docker run -d \
--name hanzo-sign \
-p 3000:3000 \
--env-file .env \
-v hanzo-sign-base:/opt/hanzo-sign/base/data \
-v /path/to/cert.p12:/opt/hanzo-sign/cert.p12:ro \
hanzo-sign/hanzo-sign:latest
```
@@ -242,11 +256,15 @@ docker rm hanzo-sign
### Start a new container with the same configuration
Reattach the same `base-data` volume so the new container uses the existing
database:
```bash
docker run -d \
--name hanzo-sign \
-p 3000:3000 \
--env-file .env \
-v hanzo-sign-base:/opt/hanzo-sign/base/data \
-v /path/to/cert.p12:/opt/hanzo-sign/cert.p12:ro \
hanzo-sign/hanzo-sign:latest
```
@@ -255,29 +273,38 @@ docker run -d \
</Steps>
<Callout type="info">
Database migrations run automatically when the container starts. Back up your database before
upgrading.
Database migrations run automatically when the container starts. Snapshot your
`sign.db` file before upgrading.
</Callout>
## Persistence
The Hanzo eSign container is stateless. All persistent data is stored in the PostgreSQL database.
The Hanzo eSign container's code is stateless, but its data is not — all
persistent data is stored in the SQLite database file on the volume mounted at
`BASE_PATH`. That volume is the thing to keep and back up; if it is lost, so is
your data.
By default, documents are also stored in the database. For high-volume deployments, configure S3-compatible storage instead. See [Storage Configuration](/docs/self-hosting/configuration/storage) for S3 setup.
By default, documents are also stored in the database (and therefore on the same
volume). For high-volume deployments, configure S3-compatible storage instead to
keep the `sign.db` file lean. See [Storage Configuration](/docs/self-hosting/configuration/storage) for S3 setup.
## Troubleshooting
<Accordions type="multiple">
<Accordion title="Container fails to start">
Check the container logs: `docker logs hanzo-sign`. Common causes: missing required environment
variables (ensure all required variables are set), database connection failed (verify the
database URL and network connectivity), port already in use (change the host port or stop the
conflicting service).
variables (ensure `BASE_PATH` and `DATABASE_URL` are set), the volume at `BASE_PATH` not mounted
or not writable, port already in use (change the host port or stop the conflicting service).
</Accordion>
<Accordion title="Database connection errors">
Test connectivity: `docker run --rm postgres:15 psql
"postgresql://user:password@host:5432/database" -c "SELECT 1"`. If using Docker networks, ensure
the container can reach the database.
<Accordion title="unable to open database file">
The directory in `DATABASE_URL` does not exist or is not writable by the app user (UID 1001).
Verify the volume is mounted and writable, then test the database file:
`docker exec hanzo-sign sqlite3 /opt/hanzo-sign/base/data/_dev/sign.db 'SELECT 1'`.
</Accordion>
<Accordion title="Data disappeared after restart">
`BASE_PATH` pointed at ephemeral container storage instead of a persistent volume. Recreate the
container with `-v hanzo-sign-base:/opt/hanzo-sign/base/data` and restore from your last
snapshot.
</Accordion>
<Accordion title="Certificate errors">
Check certificate status: `curl http://localhost:3000/api/certificate-status`. Common issues:
@@ -300,7 +327,7 @@ By default, documents are also stored in the database. For high-volume deploymen
## See Also
- [Docker Compose Deployment](/docs/self-hosting/deployment/docker-compose) - Multi-container setup with PostgreSQL included
- [Docker Compose Deployment](/docs/self-hosting/deployment/docker-compose) - Single-service setup with a managed named volume
- [Environment Variables](/docs/self-hosting/configuration/environment) - Full configuration reference
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Set up document signing
- [Email Configuration](/docs/self-hosting/configuration/email) - Configure SMTP providers
@@ -12,7 +12,7 @@ Before starting, ensure you have:
- A Kubernetes cluster (1.24+) running and accessible
- `kubectl` configured to communicate with your cluster
- A PostgreSQL 14+ database (in-cluster or external)
- A `ReadWriteOnce` PersistentVolumeClaim for the SQLite database (mounted at `BASE_PATH`)
- SMTP credentials for sending emails
- A domain name with DNS configured
- An Ingress controller installed (nginx-ingress, traefik, etc.)
@@ -26,19 +26,26 @@ kubectl get nodes
## Architecture Overview
A typical Hanzo eSign Kubernetes deployment consists of:
esign uses an embedded SQLite database (Hanzo Base) — a single shared `sign.db`
file with tenancy enforced at the query layer (see HIP-0305), not an external
database server. The database lives on a `ReadWriteOnce` PersistentVolumeClaim
owned by a single app Pod. A typical deployment consists of:
```mermaid
flowchart TD
A["Ingress (TLS termination)"] --> B["Service (ClusterIP)"]
B --> C1["Pod (app)"]
B --> C2["Pod (app)"]
B --> C3["Pod (app)"]
C1 --> D["PostgreSQL (external or in-cluster)"]
C2 --> D
C3 --> D
B --> C["Pod (app, replicas: 1)"]
C --> D[("PersistentVolumeClaim (RWO)<br/>sign.db on persistent volume")]
```
<Callout type="warn">
SQLite is a **single-writer** database. The app Deployment must run a single
replica that owns the `ReadWriteOnce` volume. Do not scale the web tier to
multiple replicas or attach an HPA to it — concurrent writers to the same
`sign.db` produce `SQLITE_BUSY` and risk corruption. Scale vertically (more CPU
and memory) instead, and offload documents to S3 to keep the database small.
</Callout>
## Namespace Setup
Create a dedicated namespace for Hanzo eSign:
@@ -92,13 +99,14 @@ stringData:
NEXT_PRIVATE_AUTH_SECRET: 'your-generated-secret-here'
NEXT_PRIVATE_ENCRYPTION_KEY: 'your-encryption-key-min-32-chars'
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: 'your-secondary-key-min-32-chars'
NEXT_PRIVATE_DATABASE_URL: 'postgresql://user:password@postgres-host:5432/hanzo-sign'
NEXT_PRIVATE_DIRECT_DATABASE_URL: 'postgresql://user:password@postgres-host:5432/hanzo-sign'
NEXT_PRIVATE_SMTP_USERNAME: 'your-smtp-username'
NEXT_PRIVATE_SMTP_PASSWORD: 'your-smtp-password'
NEXT_PRIVATE_SIGNING_PASSPHRASE: 'your-certificate-passphrase'
```
The SQLite `DATABASE_URL` and `BASE_PATH` are not secrets — they are file paths,
not credentials — so they live in the ConfigMap below.
<Callout type="warn">
Never commit Secret manifests with real values to version control. Use a secrets management tool
like Sealed Secrets, External Secrets Operator, or your cloud provider's secret manager.
@@ -133,6 +141,8 @@ data:
NEXT_PRIVATE_INTERNAL_WEBAPP_URL: 'http://localhost:3000'
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH: '/opt/hanzo-sign/cert.p12'
NEXT_PUBLIC_UPLOAD_TRANSPORT: 'database'
BASE_PATH: '/opt/hanzo-sign/base/data'
DATABASE_URL: 'file:/opt/hanzo-sign/base/data/_dev/sign.db'
```
Apply the ConfigMap:
@@ -141,6 +151,42 @@ Apply the ConfigMap:
kubectl apply -f configmap.yaml
```
## Persistent Volume
The SQLite database lives on a `ReadWriteOnce` PersistentVolumeClaim mounted at
`BASE_PATH`. Create it before the Deployment so the claim exists when the Pod
starts:
```yaml
# pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: hanzo-sign-data
namespace: hanzo-sign
labels:
app.kubernetes.io/name: hanzo-sign
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
```
Apply the PVC:
```bash
kubectl apply -f pvc.yaml
```
<Callout type="warn">
Size the volume for your document corpus if you use database storage — the
PDFs live in the same `sign.db` file. Offload to S3 to keep the volume small.
Because the claim is `ReadWriteOnce`, it can only be mounted by the single app
Pod; this is what enforces the one-writer rule.
</Callout>
## Deployment
Create the Hanzo eSign Deployment:
@@ -156,16 +202,16 @@ metadata:
app.kubernetes.io/name: hanzo-sign
app.kubernetes.io/component: web
spec:
replicas: 2
# SQLite is single-writer: exactly one Pod owns the RWO volume.
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: hanzo-sign
app.kubernetes.io/component: web
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
# Recreate (not RollingUpdate): the old Pod must release the RWO volume
# before the new Pod can mount it. A rolling surge would deadlock.
type: Recreate
template:
metadata:
labels:
@@ -221,11 +267,16 @@ spec:
timeoutSeconds: 3
failureThreshold: 30
volumeMounts:
- name: base-data
mountPath: /opt/hanzo-sign/base/data
- name: signing-cert
mountPath: /opt/hanzo-sign/cert.p12
subPath: cert.p12
readOnly: true
volumes:
- name: base-data
persistentVolumeClaim:
claimName: hanzo-sign-data
- name: signing-cert
secret:
secretName: hanzo-sign-signing-cert
@@ -366,113 +417,25 @@ kubectl apply -f ingress-nginx.yaml
kubectl apply -f ingress-traefik.yaml
```
## Database Options
## Database
### External Database (Recommended)
There is no database tier to deploy. esign's database is the embedded SQLite
`sign.db` file on the [Persistent Volume](#persistent-volume) created above — no
managed PostgreSQL service (RDS, Cloud SQL, Azure Database) to attach and no
in-cluster database StatefulSet to operate.
For production, use a managed PostgreSQL service:
Durability is a property of the volume: use a StorageClass backed by reliable,
snapshot-capable block storage, and schedule volume snapshots. See
[Backups](/docs/self-hosting/maintenance/backups).
- AWS RDS for PostgreSQL
- Google Cloud SQL
- Azure Database for PostgreSQL
- DigitalOcean Managed Databases
Update `NEXT_PRIVATE_DATABASE_URL` in your Secret with the connection string from your provider.
### In-Cluster PostgreSQL
For testing or development, deploy PostgreSQL in the cluster:
```yaml
# postgres.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
namespace: hanzo-sign
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Secret
metadata:
name: postgres-secrets
namespace: hanzo-sign
type: Opaque
stringData:
POSTGRES_USER: hanzo-sign
POSTGRES_PASSWORD: your-secure-password
POSTGRES_DB: hanzo-sign
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: hanzo-sign
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: postgres
template:
metadata:
labels:
app.kubernetes.io/name: postgres
spec:
containers:
- name: postgres
image: postgres:15-alpine
ports:
- containerPort: 5432
envFrom:
- secretRef:
name: postgres-secrets
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-pvc
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: hanzo-sign
spec:
type: ClusterIP
ports:
- port: 5432
targetPort: 5432
selector:
app.kubernetes.io/name: postgres
```
<Callout type="warn">
In-cluster PostgreSQL is not recommended for production. It lacks high availability, automated
backups, and point-in-time recovery that managed services provide.
<Callout type="info">
For strict ordering guarantees you may model the app as a single-replica
StatefulSet with a `volumeClaimTemplate` instead of a Deployment + standalone
PVC. Either way the invariant is the same: one Pod, one `ReadWriteOnce` volume,
one writer.
</Callout>
Update your Hanzo eSign secret to use the in-cluster database:
```yaml
NEXT_PRIVATE_DATABASE_URL: 'postgresql://hanzo-sign:[email protected]:5432/hanzo-sign'
NEXT_PRIVATE_DIRECT_DATABASE_URL: 'postgresql://hanzo-sign:[email protected]:5432/hanzo-sign'
```
## Persistent Storage
## Persistent Storage (Documents)
Hanzo eSign stores documents in the database by default. For high-volume deployments, configure S3-compatible storage.
@@ -498,90 +461,48 @@ See [Storage Configuration](/docs/self-hosting/configuration/storage) for detail
## Scaling
### Horizontal Pod Autoscaler (HPA)
<Callout type="warn">
Do **not** add a HorizontalPodAutoscaler or raise `replicas` above 1 for the web
tier. esign's SQLite database is single-writer and the `ReadWriteOnce` volume
can only be mounted by one Pod. Scale vertically instead.
</Callout>
Scale Hanzo eSign based on CPU and memory utilization:
### Vertical Scaling
Give the single Pod more CPU and memory by raising its resource requests and
limits in the Deployment:
```yaml
# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: hanzo-sign
namespace: hanzo-sign
labels:
app.kubernetes.io/name: hanzo-sign
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: hanzo-sign
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 15
- type: Pods
value: 4
periodSeconds: 15
selectPolicy: Max
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: '2'
memory: 2Gi
```
Apply the HPA:
Apply the change:
```bash
kubectl apply -f hpa.yaml
kubectl apply -f deployment.yaml
```
### Pod Disruption Budget
### Reducing Database Pressure
Ensure availability during cluster maintenance:
The most effective way to keep a single replica fast is to keep the `sign.db`
file small:
```yaml
# pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: hanzo-sign
namespace: hanzo-sign
labels:
app.kubernetes.io/name: hanzo-sign
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: hanzo-sign
app.kubernetes.io/component: web
```
- Set `NEXT_PUBLIC_UPLOAD_TRANSPORT=s3` so document PDFs live in object storage
rather than the database file. See [Storage Configuration](/docs/self-hosting/configuration/storage).
- Archive old audit logs and completed documents on a schedule.
Apply the PDB:
```bash
kubectl apply -f pdb.yaml
```
<Callout type="info">
A PodDisruptionBudget is intentionally omitted. With a single replica owning a
`ReadWriteOnce` volume, a `minAvailable: 1` PDB would block voluntary node
drains entirely. Rely on the `Recreate` strategy: the Pod is rescheduled onto a
new node and re-mounts the volume there.
</Callout>
## Troubleshooting
@@ -602,12 +523,18 @@ kubectl apply -f pdb.yaml
- CrashLoopBackOff (check logs for application errors)
- Pending (check resource requests and node capacity)
</Accordion>
<Accordion title="Database connection errors">
Verify the database is accessible from the cluster:
<Accordion title="unable to open database file / database is locked">
The SQLite database is the file at `DATABASE_URL` on the mounted PVC. Confirm
the volume is mounted and the file exists, then probe it from inside the Pod:
```bash
kubectl run postgres-test --rm -it --image=postgres:15-alpine -n hanzo-sign -- psql "postgresql://user:password@host:5432/database" -c "SELECT 1"
kubectl exec -it deploy/hanzo-sign -n hanzo-sign -- ls -la /opt/hanzo-sign/base/data/_dev
kubectl exec -it deploy/hanzo-sign -n hanzo-sign -- sqlite3 /opt/hanzo-sign/base/data/_dev/sign.db 'SELECT 1'
```
Persistent `database is locked` errors almost always mean more than one Pod is
writing the same file — verify `replicas: 1` and that no second Deployment or
Job mounts the same PVC.
</Accordion>
<Accordion title="Ingress not working">
Check Ingress status:
@@ -701,6 +628,8 @@ data:
NEXT_PRIVATE_INTERNAL_WEBAPP_URL: 'http://localhost:3000'
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH: '/opt/hanzo-sign/cert.p12'
NEXT_PUBLIC_UPLOAD_TRANSPORT: 'database'
BASE_PATH: '/opt/hanzo-sign/base/data'
DATABASE_URL: 'file:/opt/hanzo-sign/base/data/_dev/sign.db'
---
apiVersion: v1
kind: Secret
@@ -712,19 +641,32 @@ stringData:
NEXT_PRIVATE_AUTH_SECRET: 'REPLACE_WITH_GENERATED_SECRET'
NEXT_PRIVATE_ENCRYPTION_KEY: 'REPLACE_WITH_32_CHAR_KEY'
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: 'REPLACE_WITH_32_CHAR_KEY'
NEXT_PRIVATE_DATABASE_URL: 'postgresql://user:password@host:5432/hanzo-sign'
NEXT_PRIVATE_DIRECT_DATABASE_URL: 'postgresql://user:password@host:5432/hanzo-sign'
NEXT_PRIVATE_SMTP_USERNAME: 'smtp-user'
NEXT_PRIVATE_SMTP_PASSWORD: 'smtp-password'
NEXT_PRIVATE_SIGNING_PASSPHRASE: 'cert-passphrase'
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: hanzo-sign-data
namespace: hanzo-sign
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hanzo-sign
namespace: hanzo-sign
spec:
replicas: 2
# SQLite is single-writer: one Pod owns the RWO volume.
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: hanzo-sign
@@ -775,11 +717,16 @@ spec:
periodSeconds: 5
failureThreshold: 30
volumeMounts:
- name: base-data
mountPath: /opt/hanzo-sign/base/data
- name: signing-cert
mountPath: /opt/hanzo-sign/cert.p12
subPath: cert.p12
readOnly: true
volumes:
- name: base-data
persistentVolumeClaim:
claimName: hanzo-sign-data
- name: signing-cert
secret:
secretName: hanzo-sign-signing-cert
@@ -823,37 +770,6 @@ spec:
name: hanzo-sign
port:
name: http
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: hanzo-sign
namespace: hanzo-sign
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: hanzo-sign
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: hanzo-sign
namespace: hanzo-sign
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: hanzo-sign
```
---
@@ -16,7 +16,7 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
- Node.js 20 or later
- npm
- PostgreSQL 14 or later
- A persistent disk for the embedded SQLite database (no separate database server — set `BASE_PATH` to a directory on durable storage)
- A Linux server (for systemd service setup)
## Install and Build
@@ -50,8 +50,8 @@ NEXT_PRIVATE_ENCRYPTION_KEY="your-encryption-key-min-32-chars"
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="your-secondary-key-min-32-chars"
NEXT_PUBLIC_WEBAPP_URL="https://your-domain.com"
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@localhost:5432/hanzo-sign"
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgresql://user:password@localhost:5432/hanzo-sign"
BASE_PATH="/opt/hanzo-sign/base/data"
DATABASE_URL="file:/opt/hanzo-sign/base/data/_dev/sign.db"
NEXT_PRIVATE_SMTP_FROM_NAME="Hanzo eSign"
NEXT_PRIVATE_SMTP_FROM_ADDRESS="[email protected]"
```
@@ -1,6 +1,6 @@
---
title: Railway
description: Deploy Hanzo eSign on Railway with automatic builds from Git, managed PostgreSQL, and built-in networking. Use the one-click template or manual setup.
description: Deploy Hanzo eSign on Railway with automatic builds from Git, a persistent volume for the embedded SQLite database, and built-in networking. Use the one-click template or manual setup.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
@@ -20,6 +20,13 @@ Before deploying, you need:
- SMTP credentials for sending emails (required for document signing requests)
- A custom domain (optional but recommended for production)
<Callout type="error">
esign stores all data in an embedded SQLite file under `BASE_PATH`. Railway's
container filesystem is **ephemeral** — without a persistent volume mounted at
`BASE_PATH`, your database is wiped on every deploy and restart. Attach a
Railway volume (shown below) before going to production.
</Callout>
## One-Click Deploy
The fastest way to deploy Hanzo eSign on Railway is using the official template:
@@ -29,7 +36,7 @@ The fastest way to deploy Hanzo eSign on Railway is using the official template:
This template automatically provisions:
- Hanzo eSign application service
- PostgreSQL database
- A persistent volume mounted at `BASE_PATH` for the SQLite database
- Required environment variables with secure defaults
{/* prettier-ignore */}
@@ -112,12 +119,13 @@ No additional build configuration is needed.
</Step>
<Step>
### Add PostgreSQL database
### Add a persistent volume
- In your project, click "New"
- Select "Database"
- Choose "PostgreSQL"
- Railway provisions the database and automatically sets `DATABASE_URL`
- Select your Hanzo eSign service
- Go to "Settings" > "Volumes"
- Click "Add Volume" and set the mount path to `/opt/hanzo-sign/base/data`
This volume holds the SQLite database and survives deploys and restarts.
</Step>
@@ -133,8 +141,8 @@ NEXT_PRIVATE_AUTH_SECRET=<generate with: openssl rand -base64 32>
NEXT_PRIVATE_ENCRYPTION_KEY=<minimum 32 character random string>
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=<minimum 32 character random string>
NEXT_PUBLIC_WEBAPP_URL=https://<your-railway-url>.up.railway.app
NEXT_PRIVATE_DATABASE_URL=${{Postgres.DATABASE_URL}}
NEXT_PRIVATE_DIRECT_DATABASE_URL=${{Postgres.DATABASE_URL}}
BASE_PATH=/opt/hanzo-sign/base/data
DATABASE_URL=file:${BASE_PATH}/sign.db
NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
NEXT_PRIVATE_SMTP_HOST=<your-smtp-host>
NEXT_PRIVATE_SMTP_PORT=587
@@ -144,8 +152,10 @@ NEXT_PRIVATE_SMTP_FROM_NAME=Hanzo eSign
[email protected]
```
<Callout type="info">
Use `${{Postgres.DATABASE_URL}}` to reference the Railway-managed PostgreSQL connection string. Railway automatically resolves this reference.
<Callout type="warn">
Point `DATABASE_URL` at a `file:` path **inside** the volume mount
(`BASE_PATH`). If it resolves anywhere else, the database lives on the ephemeral
container filesystem and is lost on the next deploy.
</Callout>
**Optional variables:**
@@ -171,57 +181,76 @@ Click "Deploy" or push a commit to trigger a new deployment. Railway builds and
Railway manages environment variables through its dashboard. The full list of configuration options is documented in [Environment Variables](/docs/self-hosting/configuration/environment).
### Referencing Railway Services
### Referencing the Volume Mount
Railway allows referencing other services in your project using the `${{ServiceName.VARIABLE}}` syntax:
Railway exposes a project variable for the volume mount path. You can reference it
when composing `DATABASE_URL` so the two stay in sync:
```bash
NEXT_PRIVATE_DATABASE_URL=${{Postgres.DATABASE_URL}}
BASE_PATH=${{RAILWAY_VOLUME_MOUNT_PATH}}
DATABASE_URL=file:${{RAILWAY_VOLUME_MOUNT_PATH}}/sign.db
```
This ensures connection strings stay in sync when Railway updates service configurations.
If you set the mount path explicitly, use the literal path instead:
```bash
BASE_PATH=/opt/hanzo-sign/base/data
DATABASE_URL=file:${BASE_PATH}/sign.db
```
## Database Setup
Railway's managed PostgreSQL handles database provisioning automatically. The database:
esign's database is an embedded SQLite file on the Railway volume — there is no
managed database addon to provision, connect to, or pay for separately. The
database:
- Runs PostgreSQL 15 by default
- Includes automatic backups on paid plans
- Provides connection pooling via the internal network
- Lives in a single `sign.db` file under `BASE_PATH` on the attached volume
- Is created and migrated automatically on first start
- Is backed up by snapshotting the Railway volume
### Database Connection
For Railway deployments, use the same connection string for both pooled and direct connections:
Point `DATABASE_URL` at a `file:` path inside the volume mount:
```bash
NEXT_PRIVATE_DATABASE_URL=${{Postgres.DATABASE_URL}}
NEXT_PRIVATE_DIRECT_DATABASE_URL=${{Postgres.DATABASE_URL}}
BASE_PATH=/opt/hanzo-sign/base/data
DATABASE_URL=file:${BASE_PATH}/sign.db
```
Hanzo eSign runs database migrations automatically on startup.
### Accessing the Database
### Inspecting the Database
To connect directly to your database for debugging or maintenance:
To inspect the database for debugging or maintenance, open a shell on the service
and use the bundled `sqlite3` CLI:
{/* prettier-ignore */}
<Steps>
<Step>
### Open the PostgreSQL service
### Open a service shell
In your Railway project dashboard, select the PostgreSQL service.
In the Railway dashboard, select your Hanzo eSign service and open a shell (or use
the Railway CLI: `railway run bash`).
</Step>
<Step>
### Open the Connect tab
### Query with sqlite3
The "Connect" tab shows the connection string and credentials.
The `sqlite3` CLI takes a plain path, not the `file:` URL:
```bash
sqlite3 "${BASE_PATH}/sign.db" 'SELECT COUNT(*) FROM "User";'
```
</Step>
<Step>
### Connect with your client
### Snapshot before changes
Use the provided connection string with your preferred PostgreSQL client.
Make a consistent copy before any manual change:
```bash
sqlite3 "${BASE_PATH}/sign.db" ".backup '${BASE_PATH}/sign-backup.db'"
```
</Step>
</Steps>
@@ -307,44 +336,24 @@ Raise the memory and CPU limits; Railway will scale within these limits based on
</Step>
</Steps>
### Horizontal Scaling
Railway supports horizontal scaling through replicas:
{/* prettier-ignore */}
<Steps>
<Step>
### Open service Settings
In the Railway dashboard, select your Hanzo eSign service and open "Settings".
</Step>
<Step>
### Enable Horizontal Scaling
Turn on "Horizontal Scaling" so Railway can run multiple instances.
</Step>
<Step>
### Set replicas
Choose how many replicas to run; Railway will load-balance traffic across them.
</Step>
</Steps>
### Horizontal Scaling Is Not Supported
<Callout type="warn">
Hanzo eSign stores sessions in the database, so horizontal scaling works without additional
configuration. However, ensure your database can handle the increased connection load.
Do not enable Railway "Horizontal Scaling" (multiple replicas) for esign. The
database is a single-writer SQLite file on one volume; multiple instances cannot
safely write to it. Run a single replica and scale vertically.
</Callout>
### Database Scaling
### Volume Scaling
For high-traffic deployments:
For high-traffic or document-heavy deployments:
- Upgrade to a larger PostgreSQL instance in Railway
- Consider using connection pooling with PgBouncer
- Configure read replicas for read-heavy workloads
- Increase the persistent volume size in the service's "Volumes" settings as the
`sign.db` file grows.
- Set `NEXT_PUBLIC_UPLOAD_TRANSPORT=s3` so document PDFs live in object storage
instead of the database file, keeping the volume small. See
[Storage Configuration](/docs/self-hosting/configuration/storage).
- Raise the service's memory and CPU limits (see Vertical Scaling above).
## Cost Estimate
@@ -429,16 +438,26 @@ See [Signing Certificate Configuration](/docs/self-hosting/configuration/signing
Check the build logs in Railway for specific errors. Common causes: out of memory (increase the build memory limit in service settings), Docker build timeout (Railway has a 20-minute build timeout; ensure your build completes within this limit).
</Accordion>
<Accordion title="Application crashes on startup">
View the deployment logs: click your service, go to Deployments, click the failed deployment. Common issues: missing environment variables (verify all required variables are set), database connection failed (check `NEXT_PRIVATE_DATABASE_URL` references the correct PostgreSQL service), invalid encryption keys (ensure keys are at least 32 characters).
View the deployment logs: click your service, go to Deployments, click the failed deployment. Common issues: missing environment variables (verify all required variables are set), database file unreachable (check `DATABASE_URL` is a `file:` path under the volume mount and `BASE_PATH` matches the volume), invalid encryption keys (ensure keys are at least 32 characters).
</Accordion>
<Accordion title="Emails not sending">
Verify SMTP configuration: check `NEXT_PRIVATE_SMTP_TRANSPORT` is set to `smtp-auth`, verify SMTP credentials are correct, ensure your SMTP provider allows connections from Railway's IP addresses.
</Accordion>
<Accordion title="Database connection errors">
Verify PostgreSQL service is running, check the variable reference syntax `${{Postgres.DATABASE_URL}}`, try redeploying the application.
<Accordion title="Data disappeared after a deploy">
The SQLite file was on the ephemeral container filesystem, not the volume.
Confirm a volume is mounted at `BASE_PATH` (Settings > Volumes) and that
`DATABASE_URL` points at a `file:` path inside it, then restore from your last
volume snapshot.
</Accordion>
<Accordion title="unable to open database file">
The directory in `DATABASE_URL` does not exist or is not on the volume. Open a
service shell and verify the mount: `ls -la "$BASE_PATH"`, then probe the file:
`sqlite3 "${BASE_PATH}/sign.db" 'SELECT 1'`.
</Accordion>
<Accordion title="Slow performance">
Increase memory allocation for the service, check database query performance in Railway's metrics, consider upgrading to a larger PostgreSQL instance.
Increase memory and CPU allocation for the service, move documents to S3 with
`NEXT_PUBLIC_UPLOAD_TRANSPORT=s3` to keep `sign.db` small, and grow the volume
if it is near full.
</Accordion>
</Accordions>
@@ -12,8 +12,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
This quick start setup is for local development and testing only:
- No HTTPS (not suitable for production)
- No email delivery (emails are logged to console)
- Uses a local PostgreSQL database
- No email delivery (emails are captured by a local mail server)
- Uses an embedded SQLite database (Hanzo Base) on a local volume
- No signing certificate configured (documents will be signed with a generated certificate)
- Single-node setup (no high availability)
@@ -113,23 +113,24 @@ Log in with your credentials.
### Granting Admin Access
All accounts created through signup are regular user accounts. To grant admin access, update the user's role directly in the database:
All accounts created through signup are regular user accounts. To grant admin access, update the user's role directly in the SQLite database. Run `sqlite3` against the `sign.db` file under `BASE_PATH`:
```bash
docker compose -f docker/development/compose.yml exec database \
psql -U hanzo-sign -d hanzo-sign -c "UPDATE \"User\" SET roles = '{ADMIN}' WHERE email = '[email protected]';"
sqlite3 "$BASE_PATH/_dev/sign.db" \
"UPDATE \"User\" SET roles = '[\"ADMIN\"]' WHERE email = '[email protected]';"
```
## What's Included
The quick start setup runs the following containers:
The quick start setup runs the following containers. The database is **not** one
of them — it is an embedded SQLite file on a local volume, created automatically
under `BASE_PATH`:
| Container | Purpose | Port |
| ----------- | -------------------------------- | ----- |
| `hanzo-sign` | Main application | 3000 |
| `database` | PostgreSQL database | 54320 |
| `maildev` | Local email testing server | 2500 |
| `minio` | S3-compatible storage (optional) | 9000 |
| Container | Purpose | Port |
| ------------ | ---------------------------------- | ---- |
| `hanzo-sign` | Main application | 3000 |
| `inbucket` | Local email capture (web UI) | 9000 |
| `minio` | S3-compatible storage (optional) | 9001 |
## Useful Commands
@@ -166,8 +167,8 @@ docker compose -f docker/development/compose.yml up -d --build
</Accordion>
<Accordion title="Container fails to start">
Check the logs: `docker compose -f docker/development/compose.yml logs hanzo-sign`. Common
issues: insufficient memory (ensure Docker has at least 2GB RAM allocated), database connection
(wait for the database container to be healthy before the app starts).
issues: insufficient memory (ensure Docker has at least 2GB RAM allocated), or the SQLite
database directory not writable (the `sign.db` file is created under `BASE_PATH` on first start).
</Accordion>
<Accordion title="Cannot access localhost:3000">
Verify containers are running: `docker compose -f docker/development/compose.yml ps`. Check if
@@ -180,7 +181,7 @@ docker compose -f docker/development/compose.yml up -d --build
## See Also
- [Docker Compose Deployment](/docs/self-hosting/deployment/docker-compose) - Production setup with PostgreSQL and email
- [Docker Compose Deployment](/docs/self-hosting/deployment/docker-compose) - Production setup with a persistent volume and email
- [Environment Variables](/docs/self-hosting/configuration/environment) - Configure all available options
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Set up document signing
- [Email Configuration](/docs/self-hosting/configuration/email) - Configure SMTP for production
@@ -147,13 +147,19 @@ Hanzo eSign runs on:
The following configurations are not supported and may not work correctly.
</Callout>
| Configuration | Reason |
| -------------- | ------------------------------------- |
| Windows native | Use Docker or WSL2 instead |
| MySQL/MariaDB | PostgreSQL-specific features required |
| SQLite | Not suitable for production workloads |
| MongoDB | Relational database required |
| Node.js < 18 | Modern JavaScript features required |
| Configuration | Reason |
| --------------------- | -------------------------------------------------------------- |
| Windows native | Use Docker or WSL2 instead |
| External MySQL/MariaDB | The database is the embedded Base SQLite store; not pluggable |
| External PostgreSQL | No longer used at runtime; see the migration note below |
| MongoDB | Relational schema required |
| Node.js < 18 | Modern JavaScript features required |
<Callout type="info">
PostgreSQL is not a runtime dependency. If you are migrating an older PostgreSQL
deployment, see [Database Configuration](/docs/self-hosting/configuration/database)
for the one-time backfill into the embedded SQLite store.
</Callout>
---
@@ -161,7 +167,7 @@ Hanzo eSign runs on:
Before proceeding to deployment, confirm you have:
- [ ] PostgreSQL 14+ database accessible
- [ ] A persistent disk/volume for the SQLite database (`BASE_PATH`)
- [ ] SMTP credentials or email provider API key
- [ ] Signing certificate (`.p12`) for document signing — see [Tips](/docs/self-hosting/getting-started/tips)
- [ ] Domain name for your Hanzo eSign instance
@@ -106,8 +106,8 @@ Check the [GitHub releases](https://github.com/hanzoai/esign/releases) page for
The Docker container runs `prisma migrate deploy` every time it starts. This means:
- **Upgrades are simple**: pull the new image, restart the container, and migrations apply automatically.
- **Always back up your database before upgrading**: if a migration fails, you need a way to restore.
- **Upgrades are simple**: pull the new image, restart the container, and migrations apply automatically against the SQLite file.
- **Always snapshot the `sign.db` file before upgrading**: if a migration fails, the file copy is your rollback. See [Backups](/docs/self-hosting/maintenance/backups).
- **First startup takes longer**: the initial migration creates all tables.
---
@@ -126,9 +126,9 @@ This tells the job system to use the internal address instead of `NEXT_PUBLIC_WE
## Database Storage vs S3
Documents are stored in PostgreSQL by default (`NEXT_PUBLIC_UPLOAD_TRANSPORT=database`). This works well for small-to-medium deployments and keeps your infrastructure simple.
Documents are stored in the embedded SQLite database (Hanzo Base) by default (`NEXT_PUBLIC_UPLOAD_TRANSPORT=database`), as base64 in the same `sign.db` file. This works well for small-to-medium deployments and keeps your infrastructure simple.
For high-volume deployments, switch to S3-compatible storage to keep your database lean:
For high-volume deployments, switch to S3-compatible storage to keep the `sign.db` file lean:
```bash
NEXT_PUBLIC_UPLOAD_TRANSPORT=s3
@@ -146,7 +146,7 @@ See [Storage Configuration](/docs/self-hosting/configuration/storage) for full s
## Background Jobs Don't Need Redis
Hanzo eSign uses a PostgreSQL-based job queue by default. No Redis, no external message broker. The job system uses your existing database to store and process background tasks like email delivery and document processing.
Hanzo eSign uses a SQLite-backed job queue by default. No Redis, no external message broker. The job system uses the embedded Base database to store and process background tasks like email delivery and document processing.
For high-throughput deployments, Hanzo eSign optionally supports [Inngest](https://www.inngest.com/) as an alternative job provider:
@@ -11,20 +11,20 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
Hanzo eSign stores data in three locations:
| Component | Location | Contains |
| --------------------- | ----------------------------- | -------------------------------------------------- |
| PostgreSQL database | Database server | Users, documents, signatures, audit logs, settings |
| Signing certificate | File system or secret manager | Private key for signing documents |
| Document storage (S3) | S3 bucket or database | Uploaded PDFs and signed documents |
| Component | Location | Contains |
| --------------------- | ----------------------------- | --------------------------------------------------- |
| SQLite database | `sign.db` file under `BASE_PATH` | Users, documents, signatures, audit logs, settings |
| Signing certificate | File system or secret manager | Private key for signing documents |
| Document storage (S3) | S3 bucket or the SQLite database | Uploaded PDFs and signed documents |
<Callout type="warn">
The signing certificate is critical. If lost, you cannot sign new documents until a new
certificate is configured. Back it up securely.
</Callout>
### Data Stored in PostgreSQL
### Data Stored in the SQLite Database
The database contains:
The embedded Base database (`sign.db`) contains:
- User accounts and authentication data
- Teams and organisation settings
@@ -33,65 +33,67 @@ The database contains:
- Audit logs and activity records
- Templates and template settings
- API tokens and webhook configurations
- The `BackgroundJob` queue
If using database storage (`NEXT_PUBLIC_UPLOAD_TRANSPORT=database`), the database also contains the actual PDF files.
If using database storage (`NEXT_PUBLIC_UPLOAD_TRANSPORT=database`), the same file also contains the actual PDF files.
---
## Database Backups
### Using pg_dump
The database is a single SQLite file under `BASE_PATH`, so backups are file
snapshots — no `pg_dump`, no replication topology. Use the `sqlite3 .backup`
command, which produces a consistent copy even while the app is running.
Create a logical backup with `pg_dump`:
| Flag | Description |
| ------ | ----------------------------------------------------- |
| `-h` | Database host |
| `-U` | Database user |
| `-d` | Database name |
| `-F c` | Custom format (compressed, supports parallel restore) |
| `-f` | Output file |
### Online Backup with sqlite3 .backup
<Tabs items={['Direct', 'Docker Compose']}>
<Tab value="Direct">
```bash
pg_dump -h localhost -U hanzo-sign -d hanzo-sign -F c -f hanzo-sign_backup.dump
sqlite3 /opt/hanzo-sign/base/data/_dev/sign.db \
".backup '/backup/hanzo-sign/sign-$(date +%Y%m%d_%H%M%S).db'"
```
</Tab>
<Tab value="Docker Compose">
```bash
docker compose exec database pg_dump -U hanzo-sign -F c hanzo-sign > hanzo-sign_backup.dump
docker compose exec hanzo-sign \
sqlite3 /opt/hanzo-sign/base/data/_dev/sign.db \
".backup '/opt/hanzo-sign/base/data/sign-backup.db'"
docker compose cp \
hanzo-sign:/opt/hanzo-sign/base/data/sign-backup.db \
"./sign-$(date +%Y%m%d_%H%M%S).db"
```
</Tab>
</Tabs>
### Backup with Timestamp
The `.backup` command copies the live database (including the WAL) into a new,
self-contained file. It is safe to run while Hanzo eSign is serving traffic.
Include timestamps in backup filenames for easier management:
### Plain File Copy (App Stopped)
If the app is stopped, an ordinary `cp` is sufficient and fastest:
```bash
pg_dump -h localhost -U hanzo-sign -d hanzo-sign -F c \
-f "hanzo-sign_$(date +%Y%m%d_%H%M%S).dump"
cp /opt/hanzo-sign/base/data/_dev/sign.db \
"/backup/hanzo-sign/sign-$(date +%Y%m%d_%H%M%S).db"
```
### Plain SQL Backup
For human-readable backups or cross-version compatibility:
```bash
pg_dump -h localhost -U hanzo-sign -d hanzo-sign -F p -f hanzo-sign_backup.sql
```
<Callout type="warn">
A plain `cp` of a live database can capture a torn write. Use `sqlite3 .backup`
for online copies; reserve `cp` for when the app is stopped.
</Callout>
### Compressed Backup
Compress backups to save storage space:
Compress snapshots to save storage space:
```bash
pg_dump -h localhost -U hanzo-sign -d hanzo-sign | gzip > hanzo-sign_backup.sql.gz
sqlite3 /opt/hanzo-sign/base/data/_dev/sign.db ".backup '/tmp/sign.db'"
gzip -c /tmp/sign.db > "hanzo-sign_$(date +%Y%m%d_%H%M%S).db.gz" && rm /tmp/sign.db
```
---