Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions .github/SECRETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ All secrets needed for CI/CD pipelines. Configure in **Settings → Secrets and
| `VPS_PORT` | SSH port | `22` |
| `VPS_SSH_KEY` | Private SSH key for the deploy user | Full PEM key |

### Container Registry (GHCR)
### Docker Hub

> [!NOTE]
> GHCR uses `GITHUB_TOKEN` automatically — no additional secrets needed for pushing images.
| Secret | Description | Example |
| -------------------- | ------------------------------------- | ------------------ |
| `DOCKERHUB_USERNAME` | Your Docker Hub username | `utsavjoshi` |
| `DOCKERHUB_TOKEN` | Access token generated via Docker Hub | `dckr_pat_xxxxxxx` |

## How to Add Secrets

Expand All @@ -31,8 +33,8 @@ Before the deploy workflow can succeed, ensure the VPS has:

1. Docker and Docker Compose installed
2. The `deploy` user with docker group access
3. Project directory at `/opt/postly` with `.env` file (`chmod 600`)
4. GHCR login configured: `docker login ghcr.io -u <github-user> -p <PAT>`
3. Project directory at `/var/www/postly` with `.env` file (`chmod 600`)
4. Docker Hub login configured: `docker login -u <username> -p <PAT>`
5. SSH key added to `~/.ssh/authorized_keys` for the deploy user

## Branch Protection Rules (Recommended)
Expand All @@ -50,9 +52,9 @@ Every deploy tags images with the Git SHA. To rollback:

```bash
ssh deploy@<VPS_HOST>
cd /opt/postly
export API_IMAGE=ghcr.io/<repo>/api:<previous-sha>
export SCRAPER_IMAGE=ghcr.io/<repo>/scraper:<previous-sha>
export BOT_IMAGE=ghcr.io/<repo>/bot:<previous-sha>
docker compose -f docker-compose.prod.yml up -d --no-deps api bot scraper
cd /var/www/postly
export API_IMAGE=<dockerhub-username>/postly-api:<previous-sha>

export BOT_IMAGE=<dockerhub-username>/postly-bot:<previous-sha>
docker compose -f docker-compose.prod.yml up -d --no-deps api bot
```
78 changes: 67 additions & 11 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,38 +1,97 @@
name: Continuous Deployment (CD)
name: CI/CD Pipeline

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
deploy:
name: Deploy to VPS
build-and-push:
name: Build & Push to Docker Hub
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Extract metadata for API
id: meta-api
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/postly-api
tags: |
type=ref,event=pr
type=raw,value=latest,enable={{is_default_branch}}
type=sha

- name: Build and push API
uses: docker/build-push-action@v5
with:
context: .
file: apps/api/Dockerfile
push: true
tags: ${{ steps.meta-api.outputs.tags }}
labels: ${{ steps.meta-api.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/postly-api:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/postly-api:buildcache,mode=max

- name: Extract metadata for Bot
id: meta-bot
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/postly-bot
tags: |
type=ref,event=pr
type=raw,value=latest,enable={{is_default_branch}}
type=sha

- name: Build and push Bot
uses: docker/build-push-action@v5
with:
context: .
file: apps/bot/Dockerfile
push: true
tags: ${{ steps.meta-bot.outputs.tags }}
labels: ${{ steps.meta-bot.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/postly-bot:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/postly-bot:buildcache,mode=max

deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
needs: build-and-push
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.REMOTE_HOST }}
username: ${{ secrets.REMOTE_USER }}
port: ${{ secrets.REMOTE_PORT }}
key: ${{ secrets.SSH_PRIVATE_KEY }}

script: |
set -e # Exit immediately on any error

# Navigate to project directory
cd /var/www/postly

# Ensure clean state and pull latest changes (handles divergent branches)
# Ensure clean state and pull latest changes
git fetch --all
git reset --hard origin/main

# Rebuild and start all services
docker compose -f docker-compose.prod.yml build --no-cache
# Pull latest images and start services
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d

# Wait for API to become healthy
Expand All @@ -57,9 +116,6 @@ jobs:
# Run database migrations (if any)
docker exec postly-api npm run migrate:up || echo "No migrations to run"

# Rebuild other services if needed
docker compose -f docker-compose.prod.yml up -d --build scraper bot

# Cleanup old images
docker image prune -f

Expand Down
51 changes: 24 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,22 @@ Complete runbook for deploying Postly to a VPS.
## Architecture Overview

```
VPS Proxy (Nginx/Traefik) → API (Express)
→ Static Web (Vite build)
VPS Proxy (Nginx/Traefik) → API (Express) → Static Web (Vite build)

Internal Docker Network:
API ←→ PostgreSQL (pgvector)
API ←→ Redis (BullMQ + Caching)
Scraper ←→ PostgreSQL
Bot ←→ PostgreSQL + Redis
```

**Stack:** Node.js API · Python Scraper · Python Discord Bot · PostgreSQL 16 + pgvector · Redis 7
**Stack:** Node.js API · Python Discord Bot · PostgreSQL 16 + pgvector · Redis 7

---

## Quick-Start Checklist

```
□ 1. Clone repo to /opt/postly, create .env
□ 1. Clone repo to /var/www/postly, create .env
□ 2. docker compose -f docker-compose.prod.yml up -d
□ 3. Verify all services healthy
□ 4. Configure GitHub Actions secrets
Expand All @@ -42,7 +40,7 @@ Internal Docker Network:
Log into your VPS and run:

```bash
cd /opt/postly
cd /var/www/postly
git clone https://github.com/<your-repo>.git .

# Create production .env from template
Expand All @@ -60,11 +58,11 @@ nano .env
- `DISCORD_BOT_TOKEN` — From Discord Developer Portal
- `WEB_URL` — Your production domain (e.g., `https://postly.io`)

### 2. Login to GHCR
### 2. Login to Docker Hub

```bash
# Login to pull pre-built images from GitHub Container Registry
echo "<YOUR_PAT>" | docker login ghcr.io -u <github-username> --password-stdin
# Login to pull pre-built images from Docker Hub
echo "<YOUR_PAT>" | docker login -u <dockerhub-username> --password-stdin
```

### 3. Start the Stack
Expand All @@ -90,10 +88,10 @@ Expected health response:
}
```

### 4. Run HNSW Index Migration (One-time)
### 4. Run Database Migrations

```bash
docker exec -i postly-postgres psql -U postly -d postly < scripts/add-hnsw-indexes.sql
docker exec postly-api npm run migrate:up
```

### 5. Setup Backups
Expand All @@ -106,7 +104,7 @@ chmod +x scripts/backup.sh
bash scripts/backup.sh

# Add to cron (runs daily at 2 AM)
(crontab -l 2>/dev/null; echo "0 2 * * * /opt/postly/scripts/backup.sh >> /var/log/postly-backup.log 2>&1") | crontab -
(crontab -l 2>/dev/null; echo "0 2 * * * /var/www/postly/scripts/backup.sh >> /var/log/postly-backup.log 2>&1") | crontab -
```

### 6. Configure GitHub Actions
Expand All @@ -129,11 +127,11 @@ Push to `main` and verify the pipeline deploys successfully.
Every deploy tags images with the Git SHA. To rollback:

```bash
cd /opt/postly
export API_IMAGE=ghcr.io/<repo>/api:<previous-sha>
export SCRAPER_IMAGE=ghcr.io/<repo>/scraper:<previous-sha>
export BOT_IMAGE=ghcr.io/<repo>/bot:<previous-sha>
docker compose -f docker-compose.prod.yml up -d --no-deps api bot scraper
cd /var/www/postly
export API_IMAGE=<dockerhub-username>/postly-api:<previous-sha>

export BOT_IMAGE=<dockerhub-username>/postly-bot:<previous-sha>
docker compose -f docker-compose.prod.yml up -d --no-deps api bot
```

---
Expand Down Expand Up @@ -164,19 +162,18 @@ docker rm -f pg-restore-test
| ------- | ----------------------------------------------- | ----------- |
| 0–1K | Current setup, no changes | — |
| 1K–10K | Add Postgres read replica (second VPS) | +€4.5/mo |
| 10K–50K | Extract scraper to own VPS, add pgBouncer | +€4.5/mo |
| 10K–50K | Add pgBouncer | +€4.5/mo |
| 50K+ | Consider managed DB, split into domain services | Variable |

---

## File Reference

| File | Purpose |
| ------------------------------ | -------------------------------- |
| `docker-compose.prod.yml` | Main production stack |
| `scripts/backup.sh` | Daily PostgreSQL backup |
| `scripts/add-hnsw-indexes.sql` | pgvector HNSW indexes (run once) |
| `.env.production.example` | Production env template |
| `.github/workflows/deploy.yml` | CI/CD pipeline |
| `.github/workflows/ci.yml` | PR checks |
| `.github/SECRETS.md` | GitHub secrets reference |
| File | Purpose |
| ------------------------------ | ------------------------ |
| `docker-compose.prod.yml` | Main production stack |
| `scripts/backup.sh` | Daily PostgreSQL backup |
| `.env.production.example` | Production env template |
| `.github/workflows/deploy.yml` | CI/CD pipeline |
| `.github/workflows/ci.yml` | PR checks |
| `.github/SECRETS.md` | GitHub secrets reference |
1 change: 0 additions & 1 deletion apps/api/.eslintrc.cjs

This file was deleted.

36 changes: 36 additions & 0 deletions apps/api/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";

export default [
{
files: ["src/**/*.ts"],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
},
},
plugins: {
"@typescript-eslint": tsPlugin,
},
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
},
},
{
files: ["src/__tests__/**/*.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
{
ignores: ["dist/**", "node_modules/**"],
},
];
50 changes: 26 additions & 24 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,48 +7,50 @@
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint src --ext ts",
"lint": "eslint src",
"type-check": "tsc --noEmit",
"test": "vitest",
"test:watch": "vitest --watch"
},
"dependencies": {
"@dodopayments/express": "^0.2.6",
"@dodopayments/express": "^0.2.8",
"@postly/config": "*",
"@postly/database": "*",
"@postly/logger": "*",
"@postly/shared-types": "*",
"bcrypt": "^5.1.1",
"bullmq": "^5.31.3",
"bcrypt": "^6.0.0",
"bullmq": "^5.76.4",
"compression": "^1.8.1",
"cors": "^2.8.6",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-prom-bundle": "^8.0.0",
"express-rate-limit": "^8.2.1",
"helmet": "^8.0.0",
"ioredis": "^5.9.2",
"jsonwebtoken": "^9.0.2",
"mammoth": "^1.11.0",
"multer": "^2.0.2",
"express-rate-limit": "^8.4.1",
"helmet": "^8.1.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.3",
"mammoth": "^1.12.0",
"multer": "^2.1.1",
"pdf-parse": "^2.4.5",
"prom-client": "^15.1.3",
"resend": "^6.9.4",
"zod": "^3.24.1"
"resend": "^6.1.3",
"zod": "^4.4.1"
},
"devDependencies": {
"@postly/eslint-config": "*",
"@postly/typescript-config": "*",
"@types/bcrypt": "^5.0.2",
"@types/bcrypt": "^6.0.0",
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^25.2.0",
"@types/pdf-parse": "^1.1.4",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^3.0.5"
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/node": "^25.6.0",
"@types/pdf-parse": "^1.1.5",
"@types/supertest": "^7.2.0",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
"vitest": "^4.1.5"
}
}
Loading
Loading