Deploy ProjectVerse
This guide covers a single server running Docker Compose with Caddy for HTTPS, then notes for
managed platforms and for the external services (Cloudflare R2, Google sign-in, SMTP, Sentry,
GitHub and Slack). Day-2 operations (backups, restores, migrations, key rotation, incidents)
are in operations.md.
How it fits together
:80/:443 backend network (not published)
browser ──────▶ caddy ──▶ web:3000 (Next.js) ──▶ api:8080 (Rust) ──▶ db:5432 (Postgres 16)
TLS pages + /api/* proxy │
└──▶ S3/R2 bucket (attachments, backups)- Caddy terminates TLS (automatic Let's Encrypt certificates) and sends everything to the web server. It doesn't buffer Server-Sent Events or uploads and sets no body-size limit.
- web serves pages and proxies
/api/*to the API, so the browser only ever talks to one origin and theHttpOnlysession cookie works without CORS. Live events, uploads, imports and the Google callback go through route handlers that stream without the rewrite proxy's 10 MB / 30 s limits. - api applies database migrations on start and runs the background jobs (email digest, webhook deliveries).
- Only Caddy publishes ports. The API and Postgres are reachable only on the internal network.
Files: docker-compose.prod.yml, deploy/
(Caddyfile, env templates, scripts, cron/systemd examples), the two Dockerfiles
(apps/api/Dockerfile, built from the repository root, and apps/web/Dockerfile).
Single server with Docker Compose
1. Server
- A VPS with 4 GB RAM (2 vCPUs or more). The images are built on the server, and a release
build of the Rust API needs about 3 GB; on a 2 GB machine add swap, or build the images
somewhere else (
make docker-build, thendocker save projectverse-api projectverse-web | ssh server docker load). - Docker Engine with the Compose plugin v2.24 or newer (
docker compose version). - Ubuntu/Debian with automatic security updates (
unattended-upgrades) is a good default.
2. DNS
Create an A record (and AAAA if the server has IPv6) for your domain, e.g.
projectverse.example.com, pointing at the server. It must resolve before the first start,
or Caddy can't get a certificate (it retries, but Let's Encrypt rate-limits failures).
With Cloudflare DNS, start with the record set to DNS only (grey cloud). If you later turn
on the proxy (orange cloud), set SSL/TLS mode to Full (strict) and uncomment the
trusted_proxies block in deploy/Caddyfile, so rate limiting sees visitors' IPs rather than
Cloudflare's.
3. Firewall
Allow SSH, HTTP and HTTPS only:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp # HTTP/3
sudo ufw enableDocker publishes ports by writing its own iptables rules, which bypass ufw. That is why the
compose file publishes nothing but Caddy's ports: don't add ports: to db or api.
4. Configure
sudo mkdir -p /opt/projectverse && sudo chown "$USER" /opt/projectverse
git clone https://github.com/kemojal/projectverse.git /opt/projectverse # your repository URL
cd /opt/projectverse
cp deploy/prod.env.example deploy/prod.env # DOMAIN, ACME_EMAIL, POSTGRES_PASSWORD, ...
cp deploy/api.env.example deploy/api.env # API settings and secrets
chmod 600 deploy/prod.env deploy/api.env
deploy/scripts/generate-secrets.sh # prints APP_ENCRYPTION_KEY and POSTGRES_PASSWORDFill in every <placeholder>. Each variable is explained in the templates; the important ones:
| File | Variable | Notes |
|---|---|---|
prod.env | DOMAIN, ACME_EMAIL | APP_URL and CORS_ORIGINS become https://${DOMAIN} |
prod.env | POSTGRES_PASSWORD | Letters and digits. Only read when the database volume is first created |
prod.env | COMPOSE_PROFILES=backup, BACKUP_AT | Daily backups from the backup service (see operations.md) |
api.env | APP_ENCRYPTION_KEY | Generate once and store it in your password manager too: restoring a backup needs the same key |
api.env | STORAGE_BACKEND=s3, S3_* | Cloudflare R2 (below). local works, but then backups sit on the same disk as the database |
api.env | SMTP_* | Required for real sign-ups (verification codes) |
docker-compose.prod.yml itself sets API_ADDR=0.0.0.0:8080, DATABASE_URL (from
POSTGRES_PASSWORD), APP_URL, CORS_ORIGINS, COOKIE_SECURE=true, TRUST_PROXY=true and
STORAGE_DIR; those win over api.env.
Never copy a development apps/api/.env to the server. Production gets its own credentials.
5. First boot
make prod-up # = docker compose -f docker-compose.prod.yml --env-file deploy/prod.env up -d --build --waitThe first build takes several minutes (the Rust dependencies). Then:
docker compose -f docker-compose.prod.yml --env-file deploy/prod.env ps # all running / healthy
curl -sSI https://projectverse.example.com/login | head -1 # HTTP/2 200
docker compose -f docker-compose.prod.yml --env-file deploy/prod.env exec api \
curl -fsS http://127.0.0.1:8080/health/ready # database + storage OKOpen https://<DOMAIN>/signup and create the first account. Its verification code arrives by
email (with SMTP unset it is only printed in docker compose logs api).
Tip: alias pvc='docker compose -f /opt/projectverse/docker-compose.prod.yml --env-file /opt/projectverse/deploy/prod.env'
shortens the commands in this guide and in operations.md.
Demo data (optional). pvc run --rm api seed creates the Acme demo workspace with five
accounts whose password is the public demo-password-123. Only do this on a private or
throwaway instance, or change those passwords right away.
6. Upgrades
cd /opt/projectverse
pvc run --rm --no-deps api backup # take a backup first (see operations.md)
git pull
IMAGE_TAG=$(git rev-parse --short HEAD) make prod-upup --build rebuilds the images and recreates only the containers whose image or settings
changed. The API applies new migrations when it starts; they are forward-only, so rolling back
means restoring the pre-upgrade backup and starting the previous image (keep it around by
tagging builds with IMAGE_TAG, as above; docker image ls projectverse-api).
Update the base images now and then: pvc pull db caddy && make prod-up (Postgres stays on
major version 16; a major upgrade needs a dump and restore, see operations.md).
7. Downtime during upgrades
The stack runs one container per service, so an upgrade has a short gap (a few seconds while the API restarts and runs migrations, then the web server):
- Caddy retries the connection to
webfor up to 15 s before answering 502, so most requests just wait. - Browsers reconnect to the live-events stream automatically.
- Uploads in flight while the API restarts fail and must be retried.
- Long migrations extend the gap. Schedule big upgrades for a quiet hour.
True zero downtime needs two API and web instances behind a load balancer, with migrations written to be compatible with the previous release (expand, then contract). See "Scaling" in operations.md for what changes with several API instances.
8. Logs
make prod-logs # everything, follow
pvc logs api --since 1h # one service
pvc logs caddy | grep '"status":5' # server errors at the edgeLogs rotate at 10 MB × 5 files per container. The API's level is RUST_LOG in api.env;
Caddy's access log is JSON with one-time tokens in URLs redacted. Ship logs elsewhere (Loki,
Better Stack, CloudWatch agent, ...) if you need history beyond that.
Managed platforms (Fly.io, Render, Railway)
The same two images work on any container platform. What to set up:
- Postgres 16 from the platform (or Neon, Supabase, ...). Set
DATABASE_URLon the API, usually with?sslmode=require. If the database is a different major version, build the API image with--build-arg PG_MAJOR=<version>sopg_dumpmatches. - API service from
apps/api/Dockerfilewith the repository root as build context. Internal port8080, health check path/health. All variables fromdeploy/api.env.example, includingAPI_ADDR=0.0.0.0:8080,COOKIE_SECURE=trueandSTORAGE_BACKEND=s3(container disks are ephemeral). Make the API private if the platform allows it, reachable only from the web service, and only then setTRUST_PROXY=true. - Web service from
apps/web/Dockerfilewithapps/webas build context, port3000, public, with your domain.API_URLmust be set at build time (build argument) to the API's private address, because it is baked into the/api/*rewrite. Set the same value as a runtime variable for the route handlers.NEXT_PUBLIC_SENTRY_DSNis also a build argument. APP_URLandCORS_ORIGINSon the API are the web service's public URL.- Backups: a scheduled job running the API image with the command
backupand the API's variables (the image includespg_dump). Keep the platform's own database backups enabled too.
| Platform | API (private) | API_URL build argument | Scheduled backup |
|---|---|---|---|
| Fly.io | an app with no public IP, reached over Flycast (fly ips allocate-v6 --private) | http://<api-app>.flycast:8080 via [build.args] in the web app's fly.toml | fly machine run <api-image> backup --schedule daily |
| Render | a Private Service | http://<api-service>:8080; Render passes environment variables to Docker builds as build arguments | a Cron Job service using the API image, command backup |
| Railway | a service without a public domain | http://<api-service>.railway.internal:8080; set it as a service variable (Railway passes variables to Dockerfile ARGs) | a cron schedule on a second service from the API image, start command backup |
Dokploy
Dokploy builds the repository with docker-compose.dokploy.yml (the same
images as above, without Caddy: Dokploy's Traefik terminates TLS).
- Repository access. In Dokploy → SSH Keys, generate a key; add its public key to the GitHub repository as a read-only deploy key (Settings → Deploy keys).
- Service. In a project, create a Compose service: provider Git, URL
git@github.com:kemojal/projectverse.git, branchmain, the SSH key from step 1, compose path./docker-compose.dokploy.yml. Turn on Autodeploy to redeploy on every push tomain(Dokploy shows a webhook URL for that, or use its GitHub integration). - Environment. Paste
deploy/dokploy.env.exampleinto the Environment tab and fill it in (DOMAIN,POSTGRES_PASSWORD,APP_ENCRYPTION_KEY, R2, SMTP, Google). Dokploy writes it to.envnext to the compose file. - Domain. Domains → add
DOMAINfor serviceweb, port3000, HTTPS with Let's Encrypt. Point the domain's DNS A record at the Dokploy server (with Cloudflare: DNS only, so Let's Encrypt and server-sent events reach Traefik directly). - Deploy. The first build takes a while (Rust release build). Then open
https://DOMAIN/login;https://DOMAIN/api/…is served through the web app. To seed demo data (optional, not for real use): open a terminal on theapicontainer and runseed.
Backups run in the backup service (daily at BACKUP_AT, UTC) into the R2 bucket under
backups/. Use a bucket of its own for production: dumps are pruned by age across backups/,
and restore.sh --latest takes the newest one.
Cloudflare R2 (attachments and backups)
- In the Cloudflare dashboard, open R2 and use the existing private bucket
projectverse-attachments, or create one. Leave public access and ther2.devsubdomain off: the API streams files to users after its own permission checks. - R2 → Manage API tokens → Create API token: permission Object Read & Write, scoped to that bucket only (not account-wide), no expiry or a long one you track. Copy the Access Key ID and Secret Access Key (shown once).
- In
deploy/api.env:STORAGE_BACKEND=s3 S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com S3_BUCKET=projectverse-attachments S3_REGION=auto S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... - After
make prod-up,/health/readychecks that the bucket is reachable. Upload an attachment to confirm.
Attachments are stored at attachments/<key> and database backups at backups/YYYY/MM/DD/…
in the same bucket. Because both live in one bucket under one token, keep a second copy of the
backups somewhere else (operations.md, "Off-site copy").
Moving from local to s3 later. Local files sit at STORAGE_DIR/<k[0..2]>/<k[2..4]>/<key>.
S3 objects are flat, at attachments/<key>. Flatten the files (skipping .staging/ and
backups/) and upload them, e.g. with rclone (an r2 remote configured for the bucket):
mkdir flat
docker run --rm -v projectverse_uploads:/data:ro -v "$PWD/flat:/flat" alpine sh -c \
'find /data/uploads -type f -not -path "*/.staging/*" -not -path "*/backups/*" -exec cp {} /flat/ \;'
rclone copy ./flat r2:projectverse-attachments/attachments/ --progressThen switch STORAGE_BACKEND=s3, restart (make prod-up), open a few attachments, and
delete flat/.
Google sign-in
- Google Cloud console → APIs & Services → OAuth consent screen: External, app name, support email, authorized domain = your domain. Publish it ("In production"), or only test users can sign in.
- Credentials → Create credentials → OAuth client ID → Web application.
- Authorized redirect URI:
https://<DOMAIN>/api/auth/google/callback(exactly{APP_URL}/api/auth/google/callback). - Authorized JavaScript origin:
https://<DOMAIN>(optional).
- Authorized redirect URI:
- Set
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETinapi.env(both or neither) and restart the API. The sign-in button appears when/api/auth/providersreports Google.
Email (SMTP)
Use a transactional provider (Postmark, Amazon SES, Resend, Mailgun, ...), not a personal mailbox:
SMTP_PORT=587uses STARTTLS, any other port implicit TLS (465).SMTP_FROMmust be on a domain you've verified with the provider. Set up SPF, DKIM and a DMARC record, or verification codes land in spam.- Many VPS providers block outbound SMTP ports by default; check before launch.
- Without
SMTP_HOST, emails are only logged, so nobody else can finish signing up.
Error reporting (Sentry)
- Create two Sentry projects (Rust and Next.js).
- API:
SENTRY_DSNindeploy/api.env, then restart the API. - Web:
NEXT_PUBLIC_SENTRY_DSNindeploy/prod.env. It is a build argument, so rebuild the web image afterwards (make prod-up). - Neither sends request bodies, cookies or authorization headers. Leave them empty to turn reporting off.
GitHub and Slack integrations, outgoing webhooks
- GitHub: creating the integration in workspace settings shows a webhook URL,
https://<DOMAIN>/api/integrations/github/<id>/webhook, and a secret (shown once). In the GitHub repository: Settings → Webhooks → Add webhook, paste both, content typeapplication/json, events: Pull requests. GitHub must be able to reach the URL from the internet: no VPN, IP allow-list or basic auth in front. With Cloudflare's proxy on, make sure WAF or Bot Fight Mode rules don't challenge/api/integrations/*. GitHub's "Recent Deliveries" tab shows failures. - Slack: create a Slack app with an Incoming Webhook for the channel and paste the
https://hooks.slack.com/services/…URL. The server needs outbound HTTPS. - Outgoing webhooks may only target public addresses (
ALLOW_PRIVATE_WEBHOOK_URLSstaysfalsein production). Receivers verifyX-ProjectVerse-Signature. - Links in Slack messages and emails use
APP_URL, so it must be the publichttps://URL. - Webhook secrets, Slack URLs, GitHub secrets and 2FA seeds are encrypted with
APP_ENCRYPTION_KEY. See operations.md before ever changing it.
AI app connectors (MCP and OAuth)
Claude, ChatGPT, Claude Code and Codex connect to https://<DOMAIN>/api/mcp and sign in with
OAuth. Setup for users is in connectors.md. For the deployment:
APP_URLmust be the publichttps://<DOMAIN>. It's the OAuth issuer and the base of every URL the apps are sent to (consent page, token endpoint).https://<DOMAIN>/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resource/api/mcpmust return JSON. The web app proxies/.well-known/*to the API. If you get the login page instead, the web image is older than the connectors release.- Claude and ChatGPT reach the server from their own clouds, so
/.well-known/*,/api/mcpand/api/oauth/*must be reachable from the internet. With Cloudflare's proxy on, make sure no WAF or bot rule challenges them. - Nothing else to configure: apps register themselves, and connections live in the database. Expired codes and tokens, and app registrations nobody connected within 30 days, are pruned by the API's background job.
Production checklist
Secrets and accounts:
- Revoke the credentials leaked in the old public repositories
kemojal/project_verse*: delete the Gmail app password (Google Account → Security → 2-Step Verification → App passwords) and rotate the Twilio auth token (Twilio Console → Account → API keys & tokens). Rotation is what matters: rewriting git history doesn't un-leak a secret. Make those repositories private or archive them. - Every production secret is new and used only in production:
APP_ENCRYPTION_KEY,POSTGRES_PASSWORD, SMTP password, R2 token, Google client secret, Sentry DSNs. Nothing is reused from a development.env. -
deploy/api.envanddeploy/prod.envarechmod 600and not in git (git statusshows them as ignored). -
APP_ENCRYPTION_KEYand the env files are also stored in a password manager. A backup can't be fully restored without the key. - The R2 token is scoped to one bucket, and the bucket isn't public.
Configuration:
-
COOKIE_SECURE=true,TRUST_PROXY=true(set by compose),APP_URL=https://<DOMAIN>. -
STORAGE_BACKEND=s3,/health/readyreturns 200. - SMTP works: sign up with a fresh address and get the code. SPF/DKIM/DMARC pass.
- Google redirect URI matches
https://<DOMAIN>/api/auth/google/callback(if enabled). -
ALLOW_PRIVATE_WEBHOOK_URLSisfalseor unset. -
RUST_LOGisn'tdebug(logs grow fast and include more detail). - Demo seed data isn't present, or its passwords were changed.
Server:
- Firewall allows only 22, 80 and 443.
pvc psshows ports only oncaddy. - SSH: key-only login, no root login. Automatic security updates on.
- HTTPS works and
http://redirects tohttps://. Check the certificate at https://www.ssllabs.com/ssltest/.
Backups and monitoring:
- Backups run daily (
pvc logs backup, or the systemd timer / cron log) and appear underbackups/in the bucket. - A test restore has been done (operations.md, "Monthly restore test"), and is on the calendar monthly.
- An off-site copy of the backups exists (operations.md).
- Uptime monitoring checks
https://<DOMAIN>/login. Sentry alerts reach someone. - Disk space alerting (Postgres and Docker images grow).