memobill

Self-Hosting

Deploy MemoBILL with Docker Compose for production use.

Docker Compose

The repository includes a complete container stack:

ServicePurposeHost port
gatewayNginx entry point and request router3000
frontendNext.js pages and server renderingInternal only
backendNext.js API, auth, webhooks, and MCPInternal only
migrateOne-shot committed Drizzle migrationsNone
postgresPostgreSQL 16 with persistent storage5433 (loopback only)
minioPrivate S3-compatible object storage9000 API, 9001 console (loopback only)
minio-initOne-shot private bucket provisioningNone

The gateway sends /api/* and /.well-known/* to the backend and everything else to the frontend. Both application containers use the same immutable image, while only the gateway is exposed to application traffic.

Production Deployment

Create the environment file

cp .env.example .env

The bundled values are safe defaults for local development only. Use the production configuration below before exposing the stack outside your machine.

Build and start the complete stack

docker compose up -d --build

Compose waits for PostgreSQL and MinIO health checks, applies committed database migrations, configures MinIO's browser origin, creates the private memobill bucket, and then starts the frontend, backend, and gateway.

Verify service health

docker compose ps
curl --fail http://localhost:3000/healthz
curl --fail http://localhost:3000/api/health

Inspect or stop the stack

docker compose logs -f gateway frontend backend
docker compose down

docker compose down keeps the named database and object-storage volumes. Add --volumes only when you intentionally want to erase that data.

For hardened settings, copy .env.production.example to .env.production, fill in real secrets and the public URL, then use the production override:

docker compose -f docker-compose.yml -f docker-compose.prod.yml \
  --env-file .env.production up -d --build

Environment Variables

VariableRequiredDescription
DATABASE_URLYesPostgreSQL connection string
AUTH_SECRETYesRandom secret for JWT signing
NEXT_PUBLIC_APP_URLYesPublic URL of your instance
AUTH_GOOGLE_IDNoGoogle OAuth client ID
AUTH_GOOGLE_SECRETNoGoogle OAuth client secret
AUTH_APPLE_IDNoApple Sign In service ID
AUTH_APPLE_TEAM_IDNoApple Developer team ID
AUTH_APPLE_KEY_IDNoApple Sign In key ID
AUTH_APPLE_KEY_BASE64NoBase64-encoded .p8 private key
STRIPE_SECRET_KEYNoStripe API key for billing
STRIPE_WEBHOOK_SECRETNoStripe webhook signing secret
RESEND_API_KEYNoResend API key for platform emails
TRIGGER_PROJECT_REFYesTrigger.dev project reference for production background jobs
TRIGGER_SECRET_KEYYesTrigger.dev secret key for scheduled tasks and delayed retries
S3_BUCKETNoS3 bucket for file uploads
S3_REGIONNoS3 region
S3_ACCESS_KEY_IDNoS3 access key
S3_SECRET_ACCESS_KEYNoS3 secret key
S3_ENDPOINTNoPrivate/server S3 endpoint; omit for AWS S3
S3_PUBLIC_ENDPOINTNoBrowser-reachable endpoint used in presigned URLs
S3_FORCE_PATH_STYLENotrue for MinIO; normally false for AWS S3
MINIO_ROOT_USERDocker onlyBundled MinIO administrator/access key
MINIO_ROOT_PASSWORDDocker onlyBundled MinIO administrator/secret key
MINIO_CORS_ALLOW_ORIGINDocker onlyBrowser origin allowed to use presigned MinIO URLs

Always set a strong, unique AUTH_SECRET in production. Generate one with openssl rand -base64 32.

Authentication

MemoBILL supports three authentication methods:

  • Email & Password - Works out of the box, no additional setup needed
  • Google OAuth - Requires AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET from Google Cloud Console
  • Apple Sign In - Requires AUTH_APPLE_ID, AUTH_APPLE_TEAM_ID, AUTH_APPLE_KEY_ID, and AUTH_APPLE_KEY_BASE64 from Apple Developer. Encode your .p8 key file with base64 -w0 AuthKey_XXXXXXXXXX.p8

For OAuth providers, set the callback URL to https://your-domain.com/api/auth/callback/google (or /apple).

First User Setup

The first user to register on a new instance automatically becomes a site admin. This applies to both email/password and OAuth registration. Site admins can access the admin panel at /admin and configure site-wide settings at /admin/settings.

Registration Controls

Control who can create accounts on your instance:

ModeDescription
openAnyone can register (default)
invite_onlyOnly users with an invitation can register
disabledNo new registrations (except the first user)

Set via environment variable (REGISTRATION_MODE) or the admin settings page at /admin/settings.

Domain Restrictions

Restrict registration to specific email domains by setting ALLOWED_EMAIL_DOMAINS (comma-separated) or configuring it in admin settings. When set, only users with matching email domains can register or sign in via OAuth.

ALLOWED_EMAIL_DOMAINS="acme.com,example.com"

Self-Hosted Unlimited Mode

Without Stripe configured, all organizations automatically receive unlimited Pro features. No billing, subscriptions, or plan limits apply. This is detected automatically based on the absence of STRIPE_SECRET_KEY.

You can also control this behavior from the admin settings page:

  • Auto (default) - Unlimited when Stripe is not configured
  • Always on - Force unlimited regardless of Stripe config
  • Always off - Enforce plan limits even without Stripe

Admin Settings

Site admins can configure all self-hosting settings from /admin/settings:

  • Registration mode and allowed email domains
  • Organization creation permissions
  • Self-hosted unlimited mode toggle

Billing (Stripe)

Billing is fully optional. Without Stripe configured, all users get unlimited Pro features automatically. Site admins can also override plan limits per organization from /admin/organizations.

To enable paid plans, see the Billing & Stripe Setup guide for full instructions on creating products, prices, and configuring webhooks.

Self-hosted instances don't need Stripe. All features are automatically unlocked.

Email

Platform emails (welcome, invitations, login alerts, notification digests) use Resend. Set RESEND_API_KEY to enable these. Without it, platform emails are silently skipped.

Organization-level emails (invoices, reminders, statements) use per-org SMTP configuration set up in Settings > Email.

Background Jobs

MemoBILL uses Trigger.dev for scheduled maintenance and delayed retries. Set TRIGGER_PROJECT_REF and TRIGGER_SECRET_KEY in production before accepting traffic.

Scheduled tasks are defined in trigger/scheduled.ts:

  • Notification digests every 15 minutes
  • Report schedules hourly
  • Stripe retry hourly
  • Invoicing, bookkeeping, trash purge, and backups daily

Webhook delivery failures schedule exact delayed retry runs, so retries happen at their intended backoff time without polling. If TRIGGER_SECRET_KEY is missing when a webhook delivery fails, that retry run cannot be scheduled.

For local development:

pnpm trigger:dev

For production, deploy the Trigger.dev tasks:

pnpm trigger:deploy

Database

MemoBILL uses PostgreSQL. Minimum version: 15.

The schema is managed by Drizzle ORM. To apply schema changes:

pnpm db:push

Storage

File uploads (receipts, attachments, and backups) use the S3 API. The default Docker stack provisions a private MinIO bucket and a persistent object_storage_data volume, so it works locally without an AWS account.

Leave S3_ENDPOINT and S3_PUBLIC_ENDPOINT empty, set S3_FORCE_PATH_STYLE=false, and provide credentials through environment variables or the normal AWS SDK credential chain. Set S3_REGION and S3_BUCKET to the real bucket values. To omit the bundled storage services, start only the application path:

docker compose up -d --build postgres frontend backend gateway

In Docker, use S3_ENDPOINT=http://minio:9000 for private container traffic and S3_PUBLIC_ENDPOINT for the URL a user's browser can reach. Presigned upload and download URLs use the public value; backups and deletes use the private value.

Set both endpoint values to the appropriate R2 endpoint and keep S3_FORCE_PATH_STYLE=true unless your provider configuration requires virtual-hosted bucket URLs.

Without S3 configured, file upload features are disabled but the rest of the app works normally.

Reverse Proxy

For production, place MemoBILL behind a reverse proxy:

server {
    server_name accounting.example.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
accounting.example.com {
    reverse_proxy localhost:3000
}

Backups

Back up your PostgreSQL database regularly:

# Dump database
docker compose exec postgres pg_dump -U memobill memobill > backup.sql

# Restore
docker compose exec -T postgres psql -U memobill memobill < backup.sql

Schedule automated backups. Losing financial data can be catastrophic.