feat: Add deployment guide and update README for Coolify setup; include dotenv for environment variable management
CI-CD / Bot Lint Test Build (push) Failing after 11s
CI-CD / Dashboard Lint Build (push) Successful in 14s
CI-CD / Deploy to Coolify (push) Has been skipped

This commit is contained in:
2026-05-17 17:31:25 +02:00
parent 8041a39dfd
commit f6efd96733
11 changed files with 230 additions and 34 deletions
+138
View File
@@ -0,0 +1,138 @@
# Deployment Guide
This document covers production deployment for the `omo-bot` Coolify project with **two separate resources**:
- `omo-bot-backend` (Node.js bot + Admin API)
- `omo-bot-dashboard` (React/Vite static dashboard from `admin-dashboard/`)
## Deployment Topology
Recommended domains:
- `api.yourdomain.com` -> `omo-bot-backend`
- `admin.yourdomain.com` -> `omo-bot-dashboard`
Optional same-origin pattern:
- `admin.yourdomain.com` serves dashboard and reverse-proxies `/admin/*` + `/health` to backend.
## 1. Create Coolify Project and Resources
1. In Coolify, create (or open) project `omo-bot`.
2. Add resource `omo-bot-backend` from this repository:
- Build context: repository root
- Start command: `npm start`
- Port: `8787` (Admin API) if exposed
3. Add resource `omo-bot-dashboard` from this repository:
- Base directory: `admin-dashboard`
- Build command: `npm run build`
- Publish directory: `dist`
4. Configure domains for each resource and enable TLS certificates.
## 2. Configure Runtime Variables
Backend (`omo-bot-backend`) minimum:
- `DATABASE_URL`
- `CONFIG_DB_ENABLED=true`
- Any temporary legacy vars needed for first-time `db:seed`
Dashboard (`omo-bot-dashboard`) minimum:
- `VITE_ADMIN_API_BASE_URL` (for split-domain deployment, set to `https://api.yourdomain.com`)
- `VITE_DISCORD_CLIENT_ID`
- `VITE_DISCORD_REDIRECT_URI` (must match Discord OAuth2 redirect list)
OAuth alignment:
- Discord Developer Portal redirect URI must match dashboard redirect domain.
- Config DB key `OAUTH_BRIDGE_REDIRECT_URI` must match the same URI.
## 3. Get Coolify Deployment Hooks and Tokens
For each resource (`omo-bot-backend` and `omo-bot-dashboard`):
1. Open resource in Coolify.
2. Go to Deployments/Webhooks (name may vary by Coolify version).
3. Copy the Deploy Webhook URL.
4. If webhook auth token is enabled, copy the token.
Store both resources separately:
- Backend deploy hook URL/token
- Dashboard deploy hook URL/token
## 4. Configure Gitea Action Secrets/Variables
In Gitea repository settings, add Actions secrets:
- `COOLIFY_DEPLOY_HOOK_URL_BOT`
- `COOLIFY_DEPLOY_TOKEN_BOT` (optional)
- `COOLIFY_DEPLOY_HOOK_URL_DASHBOARD`
- `COOLIFY_DEPLOY_TOKEN_DASHBOARD` (optional)
Current workflow in `.gitea/workflows/ci-cd.yml` uses a single pair:
- `COOLIFY_DEPLOY_HOOK_URL`
- `COOLIFY_DEPLOY_TOKEN`
If you use separate resources with separate hooks, update workflow deploy step to call both hooks.
## 5. Example Deploy Step for Two Coolify Resources
```yaml
deploy-coolify:
name: Deploy to Coolify
runs-on: ubuntu-latest
needs:
- bot-checks
- dashboard-checks
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
steps:
- name: Trigger backend deploy
env:
HOOK_URL: ${{ secrets.COOLIFY_DEPLOY_HOOK_URL_BOT }}
HOOK_TOKEN: ${{ secrets.COOLIFY_DEPLOY_TOKEN_BOT }}
run: |
if [ -z "$HOOK_URL" ]; then
echo "Missing COOLIFY_DEPLOY_HOOK_URL_BOT"
exit 1
fi
if [ -n "$HOOK_TOKEN" ]; then
curl --fail --show-error --silent -X POST -H "Authorization: Bearer $HOOK_TOKEN" "$HOOK_URL"
else
curl --fail --show-error --silent -X POST "$HOOK_URL"
fi
- name: Trigger dashboard deploy
env:
HOOK_URL: ${{ secrets.COOLIFY_DEPLOY_HOOK_URL_DASHBOARD }}
HOOK_TOKEN: ${{ secrets.COOLIFY_DEPLOY_TOKEN_DASHBOARD }}
run: |
if [ -z "$HOOK_URL" ]; then
echo "Missing COOLIFY_DEPLOY_HOOK_URL_DASHBOARD"
exit 1
fi
if [ -n "$HOOK_TOKEN" ]; then
curl --fail --show-error --silent -X POST -H "Authorization: Bearer $HOOK_TOKEN" "$HOOK_URL"
else
curl --fail --show-error --silent -X POST "$HOOK_URL"
fi
```
## 6. Domain and DNS Checklist
1. Create DNS records to Coolify host:
- `A` record for root/subdomain targets
- `CNAME` where appropriate
2. Verify certificates issued for both domains.
3. Verify dashboard can call API at configured `VITE_ADMIN_API_BASE_URL`.
4. Verify API health endpoint over TLS:
- `GET https://api.yourdomain.com/health`
## 7. Post-Deploy Verification
1. `npm run register:commands` (if bot app/guild IDs changed).
2. Confirm bot online in Discord and slash commands visible.
3. Open dashboard URL and run OAuth login flow.
4. Validate Admin API auth behavior with and without bearer token.
+47 -11
View File
@@ -83,6 +83,41 @@ This project uses the arc42 template. Chapters are in `docs/`.
- npm 10+ - npm 10+
- Discord Developer Application (bot token + OAuth2 app) - Discord Developer Application (bot token + OAuth2 app)
- PostgreSQL instance - PostgreSQL instance
- Discord server where you can install bots with role/channel permissions
### Discord Bot Setup (Portal)
1. Open Discord Developer Portal and create a new application.
2. In `Bot` tab, click `Add Bot`.
3. Copy these values for runtime config (`bot_settings`):
- `DISCORD_TOKEN` from Bot tab (Reset Token if needed)
- `DISCORD_CLIENT_ID` from OAuth2 > General
4. In `Bot` > `Privileged Gateway Intents`, enable only:
- `Server Members Intent` (required by current code)
5. Keep these disabled unless code changes require them:
- `Message Content Intent`
- `Presence Intent`
Current gateway intents used by this project (`src/bot.ts`):
- `Guilds`
- `GuildMembers` (privileged)
- `GuildMessageReactions`
### Discord Bot Install (OAuth2 URL)
1. Open `OAuth2` > `URL Generator`.
2. Select scopes:
- `bot`
- `applications.commands`
3. Select bot permissions (minimum recommended for current features):
- `View Channels`
- `Send Messages`
- `Read Message History`
- `Add Reactions`
- `Manage Roles` (call-sheet role assignment)
- `Manage Channels` (queue moderation paths)
4. Use generated URL to add bot to your server.
### Installation ### Installation
@@ -155,7 +190,7 @@ npm run db:setup
``` ```
- `db:migrate`: applies schema migrations tracked in `schema_migrations` - `db:migrate`: applies schema migrations tracked in `schema_migrations`
- `db:seed`: inserts initial config values from environment into `bot_settings` when missing - `db:seed`: ensures migrations are applied, then inserts initial config values from environment into `bot_settings` when missing
- `db:setup`: runs migrate then seed - `db:setup`: runs migrate then seed
### Runtime Config Source ### Runtime Config Source
@@ -173,6 +208,16 @@ Recommended flow:
3. Run `npm run db:seed` once while legacy env vars are still present. 3. Run `npm run db:seed` once while legacy env vars are still present.
4. Remove legacy runtime vars from `.env` after confirming `bot_settings` contains required keys. 4. Remove legacy runtime vars from `.env` after confirming `bot_settings` contains required keys.
### Deployment
Deployment setup and domain configuration moved to `DEPLOYMENT.md`.
- Coolify project/resource setup
- Real domain DNS/TLS and routing patterns
- Discord OAuth production redirect alignment
- Gitea Actions secret configuration for deploy hooks
- Two-resource deploy flow (`omo-bot-backend` + `omo-bot-dashboard`)
Dashboard quality check: Dashboard quality check:
```bash ```bash
@@ -201,16 +246,7 @@ npm run build
Pipeline file: `.gitea/workflows/ci-cd.yml` Pipeline file: `.gitea/workflows/ci-cd.yml`
Flow: For full deployment flow, webhook secret strategy, and two-resource Coolify deployment, see `DEPLOYMENT.md`.
- bot checks: `npm ci -> npm run lint -> npm run build -> npm run test`
- dashboard checks: `admin-dashboard/npm ci -> npm run lint -> npm run build`
- deploy: on push to `main`, trigger Coolify deploy webhook
Required Gitea secrets:
- `COOLIFY_DEPLOY_HOOK_URL` (required)
- `COOLIFY_DEPLOY_TOKEN` (optional bearer token)
## Contributing ## Contributing
+13
View File
@@ -10,6 +10,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"discord.js": "^14.26.4", "discord.js": "^14.26.4",
"dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"pg": "^8.20.0" "pg": "^8.20.0"
}, },
@@ -3131,6 +3132,18 @@
"url": "https://github.com/discordjs/discord.js?sponsor" "url": "https://github.com/discordjs/discord.js?sponsor"
} }
}, },
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+1
View File
@@ -23,6 +23,7 @@
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"discord.js": "^14.26.4", "discord.js": "^14.26.4",
"dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"pg": "^8.20.0" "pg": "^8.20.0"
}, },
+2
View File
@@ -1,3 +1,4 @@
import "dotenv/config";
import { migrateAndSeed, runMigrations, seedInitialConfig } from "./migrations"; import { migrateAndSeed, runMigrations, seedInitialConfig } from "./migrations";
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -10,6 +11,7 @@ async function main(): Promise<void> {
} }
if (command === "seed") { if (command === "seed") {
await runMigrations();
const inserted = await seedInitialConfig(); const inserted = await seedInitialConfig();
console.log(`Database seed complete. Inserted ${inserted} config entries.`); console.log(`Database seed complete. Inserted ${inserted} config entries.`);
return; return;
+1
View File
@@ -1,3 +1,4 @@
import "dotenv/config";
import { REST, Routes } from "discord.js"; import { REST, Routes } from "discord.js";
import { commands } from "./commands"; import { commands } from "./commands";
import { loadRuntimeConfig } from "./config"; import { loadRuntimeConfig } from "./config";
+1
View File
@@ -1,3 +1,4 @@
import "dotenv/config";
import { startBot } from "./bot"; import { startBot } from "./bot";
import { loadRuntimeConfig } from "./config"; import { loadRuntimeConfig } from "./config";
import { runMigrations } from "./db/migrations"; import { runMigrations } from "./db/migrations";
+4 -1
View File
@@ -1,10 +1,13 @@
import { ChatInputCommandInteraction } from "discord.js";
import { pingCommand } from "../../src/commands/ping"; import { pingCommand } from "../../src/commands/ping";
describe("pingCommand", () => { describe("pingCommand", () => {
it("replies with Pong", async () => { it("replies with Pong", async () => {
const reply = jest.fn().mockResolvedValue(undefined); const reply = jest.fn().mockResolvedValue(undefined);
await pingCommand.execute({ reply } as any); await pingCommand.execute({
reply,
} as unknown as ChatInputCommandInteraction);
expect(reply).toHaveBeenCalledWith("Pong!"); expect(reply).toHaveBeenCalledWith("Pong!");
}); });
+4 -3
View File
@@ -20,15 +20,16 @@ describe("signUpCommand", () => {
(getTourSchedule as jest.Mock).mockReturnValue(queue); (getTourSchedule as jest.Mock).mockReturnValue(queue);
}); });
function createInteraction(action: string): any { function createInteraction(action: string) {
return { return {
inGuild: () => true, inGuild: () => true,
guildId: "guild-1", guildId: "guild-1",
user: { id: "user-1" }, user: { id: "user-1" },
guild: { id: "guild-1" }, guild: { id: "guild-1" },
memberPermissions: { memberPermissions: {
has: jest.fn((permission: bigint) => has: jest.fn(
permission === PermissionFlagsBits.ManageChannels, (permission: bigint) =>
permission === PermissionFlagsBits.ManageChannels,
), ),
}, },
options: { options: {
@@ -1,3 +1,5 @@
import { GuildMember } from "discord.js";
const mockClient = { const mockClient = {
query: jest.fn(), query: jest.fn(),
release: jest.fn(), release: jest.fn(),
@@ -53,7 +55,7 @@ describe("MileageEngine flow", () => {
}), }),
}, },
}, },
} as any; } as unknown as GuildMember;
const engine = new MileageEngine({ const engine = new MileageEngine({
databaseUrl: "postgres://test", databaseUrl: "postgres://test",
+16 -18
View File
@@ -1,4 +1,4 @@
import { ChannelType } from "discord.js"; import { ChannelType, Guild } from "discord.js";
import { TourScheduleEngine } from "../../src/tour-schedule"; import { TourScheduleEngine } from "../../src/tour-schedule";
describe("TourScheduleEngine flow", () => { describe("TourScheduleEngine flow", () => {
@@ -15,26 +15,24 @@ describe("TourScheduleEngine flow", () => {
}), }),
}, },
channels: { channels: {
fetch: jest fetch: jest.fn().mockImplementation(async (channelId: string) => {
.fn() if (channelId === "stage-1") {
.mockImplementation(async (channelId: string) => { return {
if (channelId === "stage-1") { type: ChannelType.GuildStageVoice,
return { };
type: ChannelType.GuildStageVoice, }
};
}
if (channelId === "announce-1") { if (channelId === "announce-1") {
return { return {
isSendable: () => true, isSendable: () => true,
send, send,
}; };
} }
return null; return null;
}), }),
}, },
} as any; } as unknown as Guild;
const engine = new TourScheduleEngine({ const engine = new TourScheduleEngine({
stageChannelId: "stage-1", stageChannelId: "stage-1",