Self-Hosting
Deploy MemoBILL with Docker Compose for production use.
Docker Compose
The repository includes a complete container stack:
| Service | Purpose | Host port |
|---|---|---|
gateway | Nginx entry point and request router | 3000 |
frontend | Next.js pages and server rendering | Internal only |
backend | Next.js API, auth, webhooks, and MCP | Internal only |
migrate | One-shot committed Drizzle migrations | None |
postgres | PostgreSQL 16 with persistent storage | 5433 (loopback only) |
minio | Private S3-compatible object storage | 9000 API, 9001 console (loopback only) |
minio-init | One-shot private bucket provisioning | None |
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 .envThe 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 --buildCompose 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/healthInspect or stop the stack
docker compose logs -f gateway frontend backend
docker compose downdocker 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 --buildEnvironment Variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string |
AUTH_SECRET | Yes | Random secret for JWT signing |
NEXT_PUBLIC_APP_URL | Yes | Public URL of your instance |
AUTH_GOOGLE_ID | No | Google OAuth client ID |
AUTH_GOOGLE_SECRET | No | Google OAuth client secret |
AUTH_APPLE_ID | No | Apple Sign In service ID |
AUTH_APPLE_TEAM_ID | No | Apple Developer team ID |
AUTH_APPLE_KEY_ID | No | Apple Sign In key ID |
AUTH_APPLE_KEY_BASE64 | No | Base64-encoded .p8 private key |
STRIPE_SECRET_KEY | No | Stripe API key for billing |
STRIPE_WEBHOOK_SECRET | No | Stripe webhook signing secret |
RESEND_API_KEY | No | Resend API key for platform emails |
TRIGGER_PROJECT_REF | Yes | Trigger.dev project reference for production background jobs |
TRIGGER_SECRET_KEY | Yes | Trigger.dev secret key for scheduled tasks and delayed retries |
S3_BUCKET | No | S3 bucket for file uploads |
S3_REGION | No | S3 region |
S3_ACCESS_KEY_ID | No | S3 access key |
S3_SECRET_ACCESS_KEY | No | S3 secret key |
S3_ENDPOINT | No | Private/server S3 endpoint; omit for AWS S3 |
S3_PUBLIC_ENDPOINT | No | Browser-reachable endpoint used in presigned URLs |
S3_FORCE_PATH_STYLE | No | true for MinIO; normally false for AWS S3 |
MINIO_ROOT_USER | Docker only | Bundled MinIO administrator/access key |
MINIO_ROOT_PASSWORD | Docker only | Bundled MinIO administrator/secret key |
MINIO_CORS_ALLOW_ORIGIN | Docker only | Browser 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_IDandAUTH_GOOGLE_SECRETfrom Google Cloud Console - Apple Sign In - Requires
AUTH_APPLE_ID,AUTH_APPLE_TEAM_ID,AUTH_APPLE_KEY_ID, andAUTH_APPLE_KEY_BASE64from Apple Developer. Encode your.p8key file withbase64 -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:
| Mode | Description |
|---|---|
open | Anyone can register (default) |
invite_only | Only users with an invitation can register |
disabled | No 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.
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:devFor production, deploy the Trigger.dev tasks:
pnpm trigger:deployDatabase
MemoBILL uses PostgreSQL. Minimum version: 15.
The schema is managed by Drizzle ORM. To apply schema changes:
pnpm db:pushStorage
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 gatewayIn 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.sqlSchedule automated backups. Losing financial data can be catastrophic.