Run CreativeWriter on your own server with Docker Compose. Self-hosting gives you full control over your data — your stories and all associated content stay on your infrastructure. This is for personal use only; redistribution is not permitted.
Full documentation is available in the
docs/directory.
Two release channels are available, each published to its own branch:
| Channel | Branch | Compose File | Image Tag | Updated |
|---|---|---|---|---|
| Stable | main |
docker-compose.stable.yml |
:stable |
On each release |
| Latest | develop |
docker-compose.latest.yml |
:latest |
On every push to main |
- Stable is recommended for most users — tested releases only.
- Latest tracks the development branch and may contain untested changes.
Clone the channel you want:
# Stable (recommended)
git clone --branch main https://github.com/MarcoDroll/creativewriter-selfhosted.git
cd creativewriter-selfhosted
# Or latest (bleeding edge)
git clone --branch develop https://github.com/MarcoDroll/creativewriter-selfhosted.git
cd creativewriter-selfhosted- Docker Engine 20.10+ and Docker Compose v2
- 2 GB RAM minimum (4 GB recommended)
- 10 GB disk space
Security note: The included
.envships with known default secrets so you can start immediately. These are fine for local/private use, but for any publicly accessible deployment, run./setup.shfirst to generate fresh secrets (see Production Setup below).
-
Start services
# Stable (recommended) docker compose -f docker-compose.stable.yml up -d # Or latest (bleeding edge) docker compose -f docker-compose.latest.yml up -d
-
Open the app — navigate to
http://localhost:3000
Storage buckets are initialized automatically on first boot. The included .env has working default secrets and email auto-confirm enabled — no manual configuration needed.
For any publicly accessible deployment, generate fresh secrets before starting:
./setup.sh # Generates .env with fresh secrets
# ./setup.sh --force # Overwrite existing .envThis creates random POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, and security keys.
By default, MAILER_AUTOCONFIRM=true is set so signup works without email configuration.
Warning: Without SMTP configured, password reset is impossible. Users who forget their password will have no recovery path. Even with auto-confirm enabled, consider setting up SMTP for password recovery.
For production with email confirmation and password reset, set MAILER_AUTOCONFIRM=false and configure SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, and SMTP_SENDER_EMAIL in .env.
Self-hosted CreativeWriter is single-tenant by design — one operator, one set of users you trust. Premium features (AI Rewrite, Character Chat, AI Portrait Generation) unlock via a license key issued by creativewriter.dev. There is no local Stripe path: the edge functions reject Stripe webhooks and billing-portal calls with 410 Gone when SELF_HOSTED=true.
Two ways to apply a license key:
- Per-user (recommended for shared instances) — subscribe at creativewriter.dev, open the billing page, click Generate license key, and paste it into Settings → Premium → License Key on this instance. Per-user keys take precedence over the server-wide fallback.
- Server-wide — set the
LICENSE_KEYenv var in.envto a valid signed JWT. Every user on the instance is treated as premium for as long as the key is valid (1 year from generation).
Without a license key, every user stays on the basic tier (read/write stories, Beat AI, codex, scene summaries — everything except the three premium features above). Included AI (DeepSeek) is hosted-only and never available on self-hosted, license key or not.
See docs/pricing-and-billing.md for the full license-key flow and signing details.
Studio (database admin UI) starts automatically on port 54323 and is bound to 127.0.0.1 for security. Access it at http://localhost:54323.
To expose Studio on a remote server, use an SSH tunnel: ssh -L 54323:localhost:54323 your-server.
- imgproxy (image transforms):
docker compose -f docker-compose.stable.yml --profile imgproxy up -d
Upgrades are safe — your data is stored in Docker named volumes (db-data, storage-data) which persist across container restarts and image updates.
# 1. Pull the latest compose file and config
git pull
# 2. Pull updated container images
docker compose -f docker-compose.stable.yml pull
# 3. Restart with new images (data volumes are preserved)
docker compose -f docker-compose.stable.yml up -dNote: If environment variables changed between versions, add
--force-recreate:docker compose -f docker-compose.stable.yml up -d --force-recreate
Warning: Never use
docker compose down -von an existing installation — the-vflag deletes all data volumes. Usedocker compose down(without-v) to stop services while keeping data.
Always back up before upgrading to a new version.
docker compose -f docker-compose.stable.yml exec db pg_dump -U supabase_admin postgres > backup.sqlcat backup.sql | docker compose -f docker-compose.stable.yml exec -T db psql -U supabase_admin postgresdocker compose -f docker-compose.stable.yml cp storage:/var/lib/storage ./storage-backup- Missing tables / REST 404s /
_cw_migrationsout of sync: See Migrate service issues — the most common cause is forgettinggit pullon upgrade (docker compose pulldoes not refresh host-mounted migrations). - Storage policies not working: The
storage-initservice runs automatically on startup. If it failed, re-run manually:docker compose run --rm storage-init - Auth not sending emails: Check SMTP configuration in
.env - Container won't start: Check logs with
docker compose -f docker-compose.stable.yml logs <service> - Realtime not syncing: Ensure
wal_level=logicalin PostgreSQL config (default in our compose) - GoTrue / PostgREST / Storage crash-looping with "password authentication failed": The
zz-bootstrap.shinit script sets role passwords on first run. If this happens on a fresh install, reset withdocker compose down -v && docker compose up -d. On an existing install with data, back up first (see Backup section), then reset volumes. - Realtime exits with
_realtime schema not found: Same cause —zz-bootstrap.shcreates the_realtimeschema on first init. Same fix as above. - Kong exits immediately: The entrypoint uses
sedto substituteANON_KEYandSERVICE_ROLE_KEYintokong.yml. Verify both variables are set in.env. - nginx fails to start with
directive "map" is not terminated by ";"or similar: Ensure themapblock innginx.confuses quoted regex patterns for entries containing{}. - Video compression not working (air-gapped): The video compressor loads FFmpeg WASM from a CDN (jsdelivr.net) at runtime. Air-gapped deployments without internet access cannot compress videos. Videos can still be uploaded uncompressed.