Self-Hosting Guide¶
Scribegate is designed to be trivially easy to self-host. It's a single binary with a single SQLite file for data. No external databases, no message queues, no caches.
Requirements¶
- Runtime: .NET 10 (or Docker, which bundles it)
- OS: Windows, Linux, or macOS
- RAM: 64 MB minimum, 256 MB recommended
- Disk: 100 MB for the app + your document data
- Network: One HTTP port (default 8080)
- External dependencies: None. SQLite is embedded, the frontend is bundled, no message queues or caches needed.
Option 1: Docker (Recommended)¶
The simplest path. One command, zero configuration.
docker run -d \
--name scribegate \
-p 8080:8080 \
-v scribegate-data:/data \
--restart unless-stopped \
ghcr.io/stevehansen/scribegate:latest
That's it. Open http://localhost:8080.
Docker Compose¶
For more control, use a docker-compose.yml:
services:
scribegate:
image: ghcr.io/stevehansen/scribegate:latest
ports:
- "8080:8080"
volumes:
- scribegate-data:/data
environment:
- Scribegate__BaseUrl=https://docs.example.com
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
volumes:
scribegate-data:
Updating¶
Migrations run automatically on startup. Your data is preserved in the volume.
First Run¶
When Scribegate starts for the first time on a fresh database:
- Database created — SQLite file is created at the configured data path
- Migrations applied — all tables, indexes, and constraints are set up automatically
- Default settings seeded — registration enabled, Terms of Service required, 24h account age gate
- JWT signing key generated — stored as
.jwt-keyin the data directory - ECDSA signing key generated — used for revision signatures, stored in the data directory
The first user to register becomes the instance admin. After that, registration can be toggled on or off from the admin panel.
# Register the admin account
curl -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "admin", "email": "admin@example.com", "password": "your-secure-password"}'
# Verify you're admin
curl http://localhost:8080/api/v1/auth/me \
-H "Authorization: Bearer <token-from-register-response>"
# Response includes "isAdmin": true
Option 2: dotnet publish¶
For running on bare metal or in environments where Docker isn't available.
git clone https://github.com/stevehansen/scribegate.git
cd scribegate
dotnet publish src/Scribegate.Web -c Release -o ./publish
Run it:
Or on Windows:
Running as a Service¶
Linux (systemd):
# /etc/systemd/system/scribegate.service
[Unit]
Description=Scribegate
After=network.target
[Service]
Type=exec
WorkingDirectory=/opt/scribegate
ExecStart=/opt/scribegate/Scribegate.Web
Environment=ASPNETCORE_URLS=http://+:8080
Environment=Scribegate__DataPath=/var/lib/scribegate
Restart=always
RestartSec=5
User=scribegate
[Install]
WantedBy=multi-user.target
Windows (as a service):
sc.exe create Scribegate binPath="C:\scribegate\Scribegate.Web.exe" start=auto
sc.exe start Scribegate
Option 3: Azure App Service¶
Free Tier (F1)¶
Good for evaluation. Limits: 60 CPU-minutes/day, 1 GB RAM, no custom domain.
- Create a new Web App in the Azure Portal
- Set the runtime stack to .NET 10
- Deploy via GitHub Actions, VS Code, or
az webapp deploy - Set the application setting:
Scribegate__DataPath=/home/data
The /home directory is persistent on Azure App Service.
Basic Tier (B1) — ~$13/month¶
For production use with custom domains and always-on.
Same setup as F1, but with: - Custom domain support - Always-on (no cold starts) - More CPU and memory
Option 4: fly.io¶
# Install flyctl if you haven't
curl -L https://fly.io/install.sh | sh
# Launch
fly launch --image ghcr.io/stevehansen/scribegate:latest
# Create a persistent volume
fly volumes create scribegate_data --size 1
# Set the data path
fly secrets set Scribegate__DataPath=/data
The free tier includes 3 shared-cpu VMs — plenty for Scribegate.
Configuration Reference¶
All configuration can be set via environment variables or appsettings.json.
Environment Variables¶
| Variable | Default | Description |
|---|---|---|
Scribegate__DataPath |
data |
Directory for the SQLite database file. Created automatically if it doesn't exist. |
Scribegate__BaseUrl |
http://localhost:8080 |
Public URL of the instance. Used for links in notifications and emails. |
ASPNETCORE_URLS |
http://+:8080 |
HTTP listen address. Set to http://+:443 if terminating TLS directly. |
ASPNETCORE_ENVIRONMENT |
Production |
Set to Development for detailed error pages. Never use Development in production. |
appsettings.json¶
HTTPS / TLS¶
Scribegate itself serves HTTP. For HTTPS, use a reverse proxy:
Caddy (simplest)¶
Caddy auto-provisions Let's Encrypt certificates. No configuration beyond this.
nginx¶
server {
listen 443 ssl;
server_name docs.example.com;
ssl_certificate /etc/letsencrypt/live/docs.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/docs.example.com/privkey.pem;
location / {
proxy_pass http://localhost:8080;
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;
}
}
Backup and Restore¶
Backup¶
The entire Scribegate state is one SQLite file:
# Simple file copy (stop the app first, or accept a brief moment of inconsistency)
cp /data/scribegate.db /backups/scribegate-$(date +%Y%m%d).db
# Zero-downtime backup using SQLite's backup API
sqlite3 /data/scribegate.db ".backup /backups/scribegate-$(date +%Y%m%d).db"
Restore¶
# Stop the app
docker compose down
# Replace the database
cp /backups/scribegate-20260415.db /data/scribegate.db
# Start the app (migrations will run if needed)
docker compose up -d
Automated Backup Script¶
#!/bin/bash
# backup-scribegate.sh — run via cron
BACKUP_DIR="/backups/scribegate"
DATA_DIR="/data"
RETENTION_DAYS=30
mkdir -p "$BACKUP_DIR"
sqlite3 "$DATA_DIR/scribegate.db" ".backup $BACKUP_DIR/scribegate-$(date +%Y%m%d-%H%M).db"
# Clean old backups
find "$BACKUP_DIR" -name "scribegate-*.db" -mtime +$RETENTION_DAYS -delete
Add to cron:
Troubleshooting¶
The app won't start¶
Symptom: "Unable to open database file" or "SQLite error: disk I/O error"
Fix: Check that the data directory exists and is writable by the app's user:
If using Docker, ensure the volume is mounted:
Health check fails¶
Symptom: GET /healthz returns 503 Unhealthy
Fix: The database connection is broken. Check:
1. Does the SQLite file exist? ls /data/scribegate.db
2. Is the disk full? df -h /data/
3. Are there permission issues? The app needs read/write access to both the .db file and its directory (SQLite creates -wal and -shm files alongside it)
Migration fails on startup¶
Symptom: App crashes with a migration error in the logs
Fix: This usually means the database file is corrupted or from an incompatible version:
1. Check the logs for the specific migration error
2. If the database is empty/new, delete it and let the app recreate it: rm /data/scribegate.db*
3. If you have data, restore from a backup and try again
4. If upgrading from a much older version, check the release notes for migration steps
Port already in use¶
Symptom: "Failed to bind to address" or "Address already in use"
Fix:
# Find what's using the port
lsof -i :8080
# or on Windows
netstat -ano | findstr :8080
# Change the port
# Docker: -p 9090:8080
# Direct: ASPNETCORE_URLS=http://+:9090
SQLite database is locked¶
Symptom: "database is locked" errors under load
Fix: SQLite handles concurrent reads well but serializes writes. For most Scribegate workloads (many reads, few writes), this is fine. If you're hitting lock contention:
1. Ensure WAL mode is enabled (it is by default — check with PRAGMA journal_mode;)
2. Keep write transactions short (Scribegate does this internally)
3. If you're running multiple Scribegate instances against the same file: don't. Use one instance with a reverse proxy, or switch to the RavenDB adapter for horizontal scaling.