Spaces:
Runtime error
Runtime error
Commit ·
c2dd07c
1
Parent(s): 2823b28
launching
Browse files- deploy/.gitattributes +4 -0
- deploy/Caddyfile +21 -0
- deploy/README.md +48 -0
- deploy/docker-compose.prod.yml +62 -0
- deploy/env.prod.example +62 -0
- deploy/setup.sh +39 -0
- deploy/update.sh +34 -0
deploy/.gitattributes
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.sh text eol=lf
|
| 2 |
+
Caddyfile text eol=lf
|
| 3 |
+
*.yml text eol=lf
|
| 4 |
+
env.prod.example text eol=lf
|
deploy/Caddyfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
email {$ACME_EMAIL}
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
# HTTPS certificates are issued and renewed automatically for API_DOMAIN (set in .env.prod).
|
| 6 |
+
{$API_DOMAIN} {
|
| 7 |
+
encode zstd gzip
|
| 8 |
+
|
| 9 |
+
reverse_proxy api:8000 {
|
| 10 |
+
# Stream job progress and chat (server sent events) instead of buffering them.
|
| 11 |
+
flush_interval -1
|
| 12 |
+
transport http {
|
| 13 |
+
read_timeout 0
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
# Uploads (voice samples, files) can be large.
|
| 18 |
+
request_body {
|
| 19 |
+
max_size 220MB
|
| 20 |
+
}
|
| 21 |
+
}
|
deploy/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying the backend to a DigitalOcean Droplet
|
| 2 |
+
|
| 3 |
+
One Droplet runs the API, the job worker, Caddy (automatic HTTPS) and, optionally, the video renderer.
|
| 4 |
+
The database is Neon. The frontend is on Cloudflare Pages.
|
| 5 |
+
|
| 6 |
+
## First time
|
| 7 |
+
|
| 8 |
+
1. **Droplet**: Ubuntu 24.04, 2 GB RAM for API + worker, 4 GB if you also run the renderer. Add your SSH key.
|
| 9 |
+
2. **DNS**: in Cloudflare, add an `A` record `api` pointing to the Droplet IP, **DNS only (grey cloud)** so long
|
| 10 |
+
running progress streams are not cut at 100 seconds.
|
| 11 |
+
3. **Neon**: Dashboard > Connect, turn **Connection pooling off** (host without `-pooler`), copy the string and
|
| 12 |
+
change `postgresql://` to `postgresql+psycopg://`.
|
| 13 |
+
4. On the Droplet:
|
| 14 |
+
```bash
|
| 15 |
+
git clone https://github.com/FireBird-Technologies/NotestackAI.git /opt/notestack
|
| 16 |
+
bash /opt/notestack/deploy/setup.sh # installs Docker, firewall, swap, creates deploy/.env.prod
|
| 17 |
+
nano /opt/notestack/deploy/.env.prod # fill in every <...> value
|
| 18 |
+
bash /opt/notestack/deploy/update.sh # builds, starts, waits for https://API_DOMAIN/api/health
|
| 19 |
+
```
|
| 20 |
+
Add `RENDER=1` before `update.sh` to also run the video renderer.
|
| 21 |
+
5. **Frontend**: in Cloudflare Pages set `VITE_API_URL=https://api.notestack.ai`, then retry the deployment.
|
| 22 |
+
6. **Google OAuth client**: redirect URI `https://api.notestack.ai/api/auth/google/callback`, JavaScript origin
|
| 23 |
+
`https://notestackai.pages.dev`.
|
| 24 |
+
|
| 25 |
+
## Every update
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
bash /opt/notestack/deploy/update.sh
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Useful commands (from /opt/notestack/deploy)
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
docker compose --env-file .env.prod -f docker-compose.prod.yml ps
|
| 35 |
+
docker compose --env-file .env.prod -f docker-compose.prod.yml logs -f api worker
|
| 36 |
+
docker compose --env-file .env.prod -f docker-compose.prod.yml restart worker
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## Notes
|
| 40 |
+
|
| 41 |
+
- **Why the direct Neon URL**: the worker uses Postgres advisory locks for its scheduled tasks, which do not
|
| 42 |
+
work through Neon's transaction pooler. Two processes need only a handful of connections.
|
| 43 |
+
- **Neon compute hours**: the worker polls for jobs every `WORKER_POLL_SECONDS`, so Neon's compute stays awake.
|
| 44 |
+
That is roughly 720 compute hours a month, beyond the free tier; use a paid Neon plan in production.
|
| 45 |
+
- **Files**: with `STORAGE_BACKEND=local` uploads and generated media live in the `appdata` Docker volume on the
|
| 46 |
+
Droplet (covered by Droplet backups). Switch to Cloudflare R2 before real traffic if you want them to survive
|
| 47 |
+
losing the Droplet.
|
| 48 |
+
- **Migrations** run automatically every time the API container starts.
|
deploy/docker-compose.prod.yml
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Production stack for a single DigitalOcean Droplet. The database is Neon (DATABASE_URL in .env.prod).
|
| 2 |
+
# cd /opt/notestack/deploy && docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build
|
| 3 |
+
# Caddy terminates HTTPS for API_DOMAIN and proxies to the API; the worker and renderer are internal.
|
| 4 |
+
|
| 5 |
+
services:
|
| 6 |
+
api:
|
| 7 |
+
build: ../backend
|
| 8 |
+
restart: unless-stopped
|
| 9 |
+
env_file: .env.prod
|
| 10 |
+
# Migrations run against Neon on every start (no-op when up to date). Proxy headers let the API see the
|
| 11 |
+
# real client IP and https scheme behind Caddy.
|
| 12 |
+
command: >
|
| 13 |
+
sh -c "alembic upgrade head &&
|
| 14 |
+
uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*'"
|
| 15 |
+
volumes:
|
| 16 |
+
- appdata:/data
|
| 17 |
+
healthcheck:
|
| 18 |
+
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"]
|
| 19 |
+
interval: 30s
|
| 20 |
+
timeout: 5s
|
| 21 |
+
retries: 3
|
| 22 |
+
start_period: 40s
|
| 23 |
+
|
| 24 |
+
worker:
|
| 25 |
+
build: ../backend
|
| 26 |
+
restart: unless-stopped
|
| 27 |
+
env_file: .env.prod
|
| 28 |
+
command: python -m app.worker
|
| 29 |
+
volumes:
|
| 30 |
+
- appdata:/data # same volume as the API, so both see the same stored files and corpus cache
|
| 31 |
+
depends_on:
|
| 32 |
+
api:
|
| 33 |
+
condition: service_healthy # the API has applied migrations by the time it is healthy
|
| 34 |
+
|
| 35 |
+
renderer:
|
| 36 |
+
build: ../renderer
|
| 37 |
+
restart: unless-stopped
|
| 38 |
+
environment:
|
| 39 |
+
INTERNAL_TOKEN: ${INTERNAL_TOKEN} # must match the API's
|
| 40 |
+
profiles: ["render"] # optional: start with --profile render (needs about 2 GB of free RAM)
|
| 41 |
+
|
| 42 |
+
caddy:
|
| 43 |
+
image: caddy:2
|
| 44 |
+
restart: unless-stopped
|
| 45 |
+
ports:
|
| 46 |
+
- "80:80"
|
| 47 |
+
- "443:443"
|
| 48 |
+
- "443:443/udp"
|
| 49 |
+
environment: # only what the Caddyfile needs, not the app secrets
|
| 50 |
+
API_DOMAIN: ${API_DOMAIN}
|
| 51 |
+
ACME_EMAIL: ${ACME_EMAIL}
|
| 52 |
+
volumes:
|
| 53 |
+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
| 54 |
+
- caddy_data:/data
|
| 55 |
+
- caddy_config:/config
|
| 56 |
+
depends_on:
|
| 57 |
+
- api
|
| 58 |
+
|
| 59 |
+
volumes:
|
| 60 |
+
appdata: {}
|
| 61 |
+
caddy_data: {}
|
| 62 |
+
caddy_config: {}
|
deploy/env.prod.example
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy to deploy/.env.prod on the Droplet (git ignores .env.prod) and fill in every <...> value.
|
| 2 |
+
# Generate secrets with: openssl rand -hex 32
|
| 3 |
+
|
| 4 |
+
# ---- Domains
|
| 5 |
+
API_DOMAIN=api.notestack.ai
|
| 6 |
+
ACME_EMAIL=arslan@firebird-technologies.com
|
| 7 |
+
ENV=production
|
| 8 |
+
API_URL=https://api.notestack.ai
|
| 9 |
+
FRONTEND_URL=https://notestackai.pages.dev
|
| 10 |
+
CORS_ORIGINS=https://notestackai.pages.dev
|
| 11 |
+
|
| 12 |
+
# ---- Neon Postgres
|
| 13 |
+
# In Neon: Dashboard > Connect. Pick the DIRECT connection (turn "Connection pooling" off: the host must NOT
|
| 14 |
+
# contain "-pooler"), then change the scheme from postgresql:// to postgresql+psycopg://
|
| 15 |
+
DATABASE_URL=postgresql+psycopg://<user>:<password>@<ep-xxxx>.<region>.aws.neon.tech/<db>?sslmode=require
|
| 16 |
+
# The worker checks for jobs every WORKER_POLL_SECONDS, which keeps Neon's compute awake. Raise it to cut
|
| 17 |
+
# compute hours (jobs then start a few seconds later).
|
| 18 |
+
WORKER_POLL_SECONDS=3
|
| 19 |
+
WORKER_CONCURRENCY=3
|
| 20 |
+
RUN_WORKER_IN_API=false
|
| 21 |
+
|
| 22 |
+
# ---- Secrets (new values for production, never reuse local ones)
|
| 23 |
+
JWT_SECRET=<openssl rand -hex 32>
|
| 24 |
+
INTERNAL_TOKEN=<openssl rand -hex 32>
|
| 25 |
+
UNSUBSCRIBE_SECRET=<openssl rand -hex 32>
|
| 26 |
+
|
| 27 |
+
# ---- Google sign in (add https://api.notestack.ai/api/auth/google/callback to the OAuth client)
|
| 28 |
+
GOOGLE_CLIENT_ID=<client id>
|
| 29 |
+
GOOGLE_CLIENT_SECRET=<client secret>
|
| 30 |
+
GOOGLE_REDIRECT_URI=https://api.notestack.ai/api/auth/google/callback
|
| 31 |
+
|
| 32 |
+
# ---- Email
|
| 33 |
+
EMAIL_PROVIDER=resend
|
| 34 |
+
RESEND_API_KEY=<resend key>
|
| 35 |
+
FROM_EMAIL=Notestack <hello@notestack.ai>
|
| 36 |
+
ALERTS_EMAIL=arslan@firebird-technologies.com
|
| 37 |
+
|
| 38 |
+
# ---- Files. Local disk on the Droplet (kept in the appdata Docker volume, backed up with the Droplet).
|
| 39 |
+
# For R2 instead: set STORAGE_BACKEND=r2 and fill the R2_* values.
|
| 40 |
+
STORAGE_BACKEND=local
|
| 41 |
+
LOCAL_STORAGE_DIR=/data/storage
|
| 42 |
+
CORPUS_CACHE_DIR=/data/corpus
|
| 43 |
+
R2_ACCOUNT_ID=
|
| 44 |
+
R2_ACCESS_KEY_ID=
|
| 45 |
+
R2_SECRET_ACCESS_KEY=
|
| 46 |
+
R2_BUCKET=notestack-assets
|
| 47 |
+
|
| 48 |
+
# ---- AI
|
| 49 |
+
LLM_API_KEY=<z.ai key>
|
| 50 |
+
ELEVENLABS_API_KEY=<elevenlabs key>
|
| 51 |
+
TYPESAFE_API_KEY=
|
| 52 |
+
|
| 53 |
+
# ---- Renderer (only used when started with --profile render)
|
| 54 |
+
RENDERER_URL=http://renderer:3100
|
| 55 |
+
|
| 56 |
+
# ---- Social publishing (optional). Redirect URIs: https://api.notestack.ai/api/social/{x,linkedin}/callback
|
| 57 |
+
X_CLIENT_ID=
|
| 58 |
+
X_CLIENT_SECRET=
|
| 59 |
+
LINKEDIN_CLIENT_ID=
|
| 60 |
+
LINKEDIN_CLIENT_SECRET=
|
| 61 |
+
|
| 62 |
+
BILLING_ENABLED=false
|
deploy/setup.sh
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# One time setup for a fresh Ubuntu 24.04 Droplet. Run as root:
|
| 3 |
+
# git clone https://github.com/FireBird-Technologies/NotestackAI.git /opt/notestack
|
| 4 |
+
# bash /opt/notestack/deploy/setup.sh
|
| 5 |
+
set -euo pipefail
|
| 6 |
+
|
| 7 |
+
cd "$(dirname "$0")"
|
| 8 |
+
|
| 9 |
+
echo "==> Installing Docker"
|
| 10 |
+
if ! command -v docker >/dev/null; then
|
| 11 |
+
curl -fsSL https://get.docker.com | sh
|
| 12 |
+
fi
|
| 13 |
+
|
| 14 |
+
echo "==> Firewall: SSH, HTTP, HTTPS only"
|
| 15 |
+
ufw allow OpenSSH
|
| 16 |
+
ufw allow 80/tcp
|
| 17 |
+
ufw allow 443/tcp
|
| 18 |
+
ufw allow 443/udp
|
| 19 |
+
ufw --force enable
|
| 20 |
+
|
| 21 |
+
echo "==> Swap (helps builds and the renderer on small Droplets)"
|
| 22 |
+
if [ ! -f /swapfile ]; then
|
| 23 |
+
fallocate -l 2G /swapfile
|
| 24 |
+
chmod 600 /swapfile
|
| 25 |
+
mkswap /swapfile
|
| 26 |
+
swapon /swapfile
|
| 27 |
+
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
| 28 |
+
fi
|
| 29 |
+
|
| 30 |
+
if [ ! -f .env.prod ]; then
|
| 31 |
+
cp env.prod.example .env.prod
|
| 32 |
+
chmod 600 .env.prod
|
| 33 |
+
echo
|
| 34 |
+
echo "Created deploy/.env.prod. Fill in every <...> value, then run: bash deploy/update.sh"
|
| 35 |
+
exit 0
|
| 36 |
+
fi
|
| 37 |
+
|
| 38 |
+
echo "==> .env.prod exists; starting the stack"
|
| 39 |
+
bash ./update.sh
|
deploy/update.sh
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Deploy the latest code: pull, rebuild, restart, then check health.
|
| 3 |
+
# bash /opt/notestack/deploy/update.sh # API, worker, Caddy
|
| 4 |
+
# RENDER=1 bash /opt/notestack/deploy/update.sh # also the video renderer
|
| 5 |
+
set -euo pipefail
|
| 6 |
+
|
| 7 |
+
cd "$(dirname "$0")"
|
| 8 |
+
git -C .. pull --ff-only
|
| 9 |
+
|
| 10 |
+
# Placeholders look like =<...> or <user>/<password> inside DATABASE_URL (FROM_EMAIL's <address> is fine).
|
| 11 |
+
if grep -qE '=<|<(user|password|ep-xxxx|region|db)>' .env.prod; then
|
| 12 |
+
echo "deploy/.env.prod still has <placeholders>; fill them in first."
|
| 13 |
+
exit 1
|
| 14 |
+
fi
|
| 15 |
+
|
| 16 |
+
profile=()
|
| 17 |
+
[ "${RENDER:-0}" = "1" ] && profile=(--profile render)
|
| 18 |
+
|
| 19 |
+
docker compose --env-file .env.prod -f docker-compose.prod.yml "${profile[@]}" up -d --build --remove-orphans
|
| 20 |
+
docker image prune -f >/dev/null
|
| 21 |
+
|
| 22 |
+
domain=$(grep -E '^API_DOMAIN=' .env.prod | cut -d= -f2)
|
| 23 |
+
echo "==> Waiting for https://$domain/api/health"
|
| 24 |
+
for _ in $(seq 1 30); do
|
| 25 |
+
if curl -fsS "https://$domain/api/health" >/dev/null 2>&1; then
|
| 26 |
+
curl -fsS "https://$domain/api/health"
|
| 27 |
+
echo
|
| 28 |
+
echo "==> Live."
|
| 29 |
+
exit 0
|
| 30 |
+
fi
|
| 31 |
+
sleep 4
|
| 32 |
+
done
|
| 33 |
+
echo "Health check did not pass yet. Logs: cd deploy && docker compose --env-file .env.prod -f docker-compose.prod.yml logs -f api"
|
| 34 |
+
exit 1
|