DocsSelf-hosting

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 the HttpOnly session 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, then docker 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:

bash
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp     # HTTP/3
sudo ufw enable

Docker 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

bash
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_PASSWORD

Fill in every <placeholder>. Each variable is explained in the templates; the important ones:

FileVariableNotes
prod.envDOMAIN, ACME_EMAILAPP_URL and CORS_ORIGINS become https://${DOMAIN}
prod.envPOSTGRES_PASSWORDLetters and digits. Only read when the database volume is first created
prod.envCOMPOSE_PROFILES=backup, BACKUP_ATDaily backups from the backup service (see operations.md)
api.envAPP_ENCRYPTION_KEYGenerate once and store it in your password manager too: restoring a backup needs the same key
api.envSTORAGE_BACKEND=s3, S3_*Cloudflare R2 (below). local works, but then backups sit on the same disk as the database
api.envSMTP_*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

bash
make prod-up        # = docker compose -f docker-compose.prod.yml --env-file deploy/prod.env up -d --build --wait

The first build takes several minutes (the Rust dependencies). Then:

bash
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 OK

Open 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

bash
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-up

up --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 web for 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

bash
make prod-logs                              # everything, follow
pvc logs api --since 1h                     # one service
pvc logs caddy | grep '"status":5'          # server errors at the edge

Logs 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_URL on the API, usually with ?sslmode=require. If the database is a different major version, build the API image with --build-arg PG_MAJOR=<version> so pg_dump matches.
  • API service from apps/api/Dockerfile with the repository root as build context. Internal port 8080, health check path /health. All variables from deploy/api.env.example, including API_ADDR=0.0.0.0:8080, COOKIE_SECURE=true and STORAGE_BACKEND=s3 (container disks are ephemeral). Make the API private if the platform allows it, reachable only from the web service, and only then set TRUST_PROXY=true.
  • Web service from apps/web/Dockerfile with apps/web as build context, port 3000, public, with your domain. API_URL must 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_DSN is also a build argument.
  • APP_URL and CORS_ORIGINS on the API are the web service's public URL.
  • Backups: a scheduled job running the API image with the command backup and the API's variables (the image includes pg_dump). Keep the platform's own database backups enabled too.
PlatformAPI (private)API_URL build argumentScheduled backup
Fly.ioan 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.tomlfly machine run <api-image> backup --schedule daily
Rendera Private Servicehttp://<api-service>:8080; Render passes environment variables to Docker builds as build argumentsa Cron Job service using the API image, command backup
Railwaya service without a public domainhttp://<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).

  1. 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).
  2. Service. In a project, create a Compose service: provider Git, URL git@github.com:kemojal/projectverse.git, branch main, the SSH key from step 1, compose path ./docker-compose.dokploy.yml. Turn on Autodeploy to redeploy on every push to main (Dokploy shows a webhook URL for that, or use its GitHub integration).
  3. Environment. Paste deploy/dokploy.env.example into the Environment tab and fill it in (DOMAIN, POSTGRES_PASSWORD, APP_ENCRYPTION_KEY, R2, SMTP, Google). Dokploy writes it to .env next to the compose file.
  4. Domain. Domains → add DOMAIN for service web, port 3000, 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).
  5. 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 the api container and run seed.

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)

  1. In the Cloudflare dashboard, open R2 and use the existing private bucket projectverse-attachments, or create one. Leave public access and the r2.dev subdomain off: the API streams files to users after its own permission checks.
  2. 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).
  3. 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=...
  4. After make prod-up, /health/ready checks 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):

bash
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/ --progress

Then switch STORAGE_BACKEND=s3, restart (make prod-up), open a few attachments, and delete flat/.

Google sign-in

  1. 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.
  2. 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).
  3. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in api.env (both or neither) and restart the API. The sign-in button appears when /api/auth/providers reports Google.

Email (SMTP)

Use a transactional provider (Postmark, Amazon SES, Resend, Mailgun, ...), not a personal mailbox:

  • SMTP_PORT=587 uses STARTTLS, any other port implicit TLS (465).
  • SMTP_FROM must 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_DSN in deploy/api.env, then restart the API.
  • Web: NEXT_PUBLIC_SENTRY_DSN in deploy/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 type application/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_URLS stays false in production). Receivers verify X-ProjectVerse-Signature.
  • Links in Slack messages and emails use APP_URL, so it must be the public https:// 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_URL must be the public https://<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-server and /.well-known/oauth-protected-resource/api/mcp must 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/mcp and /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.env and deploy/prod.env are chmod 600 and not in git (git status shows them as ignored).
  • APP_ENCRYPTION_KEY and 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/ready returns 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_URLS is false or unset.
  • RUST_LOG isn't debug (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 ps shows ports only on caddy.
  • SSH: key-only login, no root login. Automatic security updates on.
  • HTTPS works and http:// redirects to https://. 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 under backups/ 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).