Documentation
Everything you need to deploy, configure, and extend Bastion.
Getting Started
Bastion is designed to be easy to self-host. The quickest way to get started is with Docker Compose, which sets up the entire stack — backend, database, and cache — in a single command.
git clone https://github.com/Calmingstorm/bastion.git
cd bastion
cp .env.example .env # Edit with your settings
docker compose up -d
That's it! Open http://localhost:8080 in your browser to access your Bastion instance. The first user to register becomes the server owner.
Requirements
Minimum requirements for self-hosting:
| Component | Minimum | Recommended |
|---|---|---|
| CPU | 1 core | 2+ cores |
| RAM | 512 MB | 1+ GB |
| Storage | 1 GB | 10+ GB (for file uploads) |
| Docker | 20.10+ | Latest |
| Docker Compose | v2.0+ | Latest |
Bastion runs well on budget VPS providers like Linode, DigitalOcean, or Hetzner. A $5/month instance is sufficient for small communities.
Installation
Detailed installation steps:
1. Clone the repository
git clone https://github.com/Calmingstorm/bastion.git cd bastion
2. Configure environment
cp .env.example .env
Edit .env with your preferred settings. At minimum, change BASTION_JWT_SECRET to a random string. See Configuration for all options.
3. Start the stack
docker compose up -d
4. Verify
# Check all containers are running docker compose ps # View logs docker compose logs -f bastion
Configuration
Bastion is configured via environment variables. All variables support a BASTION_ prefix. Where noted, a shorter fallback name is also accepted.
Server
| Variable | Default | Description |
|---|---|---|
BASTION_HOST | 0.0.0.0 | HTTP server bind address |
BASTION_PORT | 8080 | HTTP server port |
BASTION_DOMAIN | http://localhost:5173 | Frontend URL (used in password reset emails) |
Database (PostgreSQL)
| Variable | Fallback | Default | Description |
|---|---|---|---|
BASTION_DB_HOST | DB_HOST | localhost | PostgreSQL host |
BASTION_DB_PORT | DB_PORT | 5432 | PostgreSQL port |
BASTION_DB_NAME | DB_NAME | bastion | Database name |
BASTION_DB_USER | DB_USER | bastion | Database user |
BASTION_DB_PASSWORD | DB_PASSWORD | bastion | Database password |
JWT Authentication
| Variable | Fallback | Default | Description |
|---|---|---|---|
BASTION_JWT_SECRET | JWT_SECRET | — | Secret for signing JWT tokens (required) |
BASTION_JWT_ACCESS_TTL | JWT_ACCESS_TTL | 15m | Access token lifetime |
BASTION_JWT_REFRESH_TTL | JWT_REFRESH_TTL | 168h | Refresh token lifetime (default 7 days) |
Redis
| Variable | Fallback | Default | Description |
|---|---|---|---|
BASTION_REDIS_HOST | REDIS_HOST | localhost | Redis host |
BASTION_REDIS_PORT | REDIS_PORT | 6379 | Redis port |
File Uploads
| Variable | Default | Description |
|---|---|---|
BASTION_UPLOAD_DIR | ./uploads | File upload storage path |
BASTION_UPLOAD_MAX_SIZE | 10MB | Maximum file upload size (supports KB, MB, GB) |
BASTION_UPLOAD_BASE_URL | /api/uploads | Base URL for serving uploaded files |
Email (SMTP)
SMTP is checked first for sending emails. Configure either SMTP or Mailgun (or both as fallback).
| Variable | Default | Description |
|---|---|---|
BASTION_SMTP_HOST | — | SMTP server host |
BASTION_SMTP_PORT | 587 | SMTP server port |
BASTION_SMTP_USER | — | SMTP username |
BASTION_SMTP_PASS | — | SMTP password |
BASTION_SMTP_FROM | Bastion <noreply@localhost> | From address for outgoing email |
Email (Mailgun)
Used as a fallback if SMTP is not configured, or as the primary sender on hosts that block outbound SMTP (e.g. Linode).
| Variable | Default | Description |
|---|---|---|
BASTION_MAILGUN_API_KEY | — | Mailgun API key |
BASTION_MAILGUN_DOMAIN | — | Mailgun sending domain |
BASTION_MAILGUN_FROM | Bastion <noreply@localhost> | From address for outgoing email |
GIF Search (Optional)
Set one of these to enable the GIF picker in the client. Tenor takes priority if both are set. The /api/v1/features endpoint reports whether GIF search is enabled.
| Variable | Description |
|---|---|
BASTION_TENOR_API_KEY | Google Cloud API key with Tenor API enabled (free tier: 5000 req/day) |
BASTION_GIPHY_API_KEY | Giphy API key (free for non-commercial use) |
Reverse Proxy
For production deployments, use a reverse proxy with automatic HTTPS. WebSocket support is required for real-time features. Here's a recommended Caddy configuration:
chat.yourdomain.com { reverse_proxy localhost:8080 }
Caddy handles WebSocket upgrades and TLS automatically. Nginx alternative:
server { server_name chat.yourdomain.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } }
Bot Development
Bastion supports bots with full API access. Bots authenticate with Authorization: Bot <token> and have their own user identity with the same role and permission system as regular users. Bot messages display a BOT badge.
Creating a Bot
- Go to Server Settings → Integrations → Bots
- Click "Create Bot" and give it a name
- Copy the bot token (prefixed with
bot_) — keep it secret! - The bot token is hashed with argon2id and cannot be recovered. Use "Regenerate Token" if lost.
Authentication
Bot requests use a different auth scheme than user JWTs. Include the token in the Authorization header:
Authorization: Bot bot_your_token_here
Example: Sending a Message
import requests TOKEN = "bot_your_token_here" BASE_URL = "https://your-instance.com/api/v1" # Send a message to a channel response = requests.post( f"{BASE_URL}/channels/{channel_id}/messages", headers={"Authorization": f"Bot {TOKEN}"}, json={"content": "Hello from my bot!"} )
WebSocket Events
Bots can connect to the WebSocket at /api/v1/ws using the same Bot token scheme to receive real-time events such as new messages, member joins, and interaction dispatches.
Webhooks
Webhooks let you send messages to channels from external services without a bot user. Create a webhook in Server Settings → Webhooks, then POST to the webhook URL. Webhook tokens are prefixed with whk_.
curl -X POST "https://your-instance.com/api/v1/webhooks/{id}/{token}" \ -H "Content-Type: application/json" \ -d '{"content": "Deployment successful!", "username": "CI Bot"}'
Webhook execution is rate-limited to 30 requests per minute per IP. No authentication header is needed — the token in the URL serves as the credential.
Slash Commands
Bots can register slash commands that users invoke with / in the message input. Bastion supports three command types:
- Type 1 — Slash commands (e.g.
/ping) - Type 2 — User context menus (right-click a user)
- Type 3 — Message context menus (right-click a message)
Registering a Command
# Register a slash command for your bot requests.post( f"{BASE_URL}/servers/{server_id}/bots/{bot_id}/commands", headers={"Authorization": f"Bot {TOKEN}"}, json={ "name": "ping", "description": "Check if the bot is alive", "type": 1 } )
Handling Interactions
When a user invokes a command, the server creates an interaction token (15-minute TTL) and dispatches an INTERACTION_CREATE event to your bot via WebSocket. Your bot responds by calling the interaction callback endpoint:
# Respond to an interaction requests.post( f"{BASE_URL}/interactions/{interaction_token}/callback", json={ "content": "Pong! Bot is alive.", "ephemeral": True # Only visible to the invoker } )
If the bot has no active WebSocket connections when a command is invoked, the server returns a 503 BOT_OFFLINE error to the user.
API Overview
Bastion exposes a RESTful API at /api/v1/. All endpoints accept and return JSON. The legacy /api/ prefix redirects to /api/v1/ for backward compatibility.
The server also provides auto-generated API documentation at /api/v1/docs and an OpenAPI 3.0 specification at /api/v1/docs/openapi.yaml.
Authentication
Protected endpoints require an Authorization header. Bastion supports two schemes:
Bearer <jwt_access_token>— for user sessionsBot <bot_token>— for bot integrations
Rate Limiting
| Scope | Limit | Applies To |
|---|---|---|
| Authentication | 5 req/min per IP | Login, register, password reset |
| Protected routes | 120 req/min per user | All authenticated endpoints |
| Message send | 10 req/10 sec per user | POST messages |
| File upload | 5 req/min per user | POST messages with attachments |
| Bulk import | 10 req/min per user | Channel message import |
| Webhooks | 30 req/min per IP | Webhook execution |
| Interaction callbacks | 30 req/min per IP | Bot interaction responses |
Authentication Endpoints
All auth endpoints are public (no token required).
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/auth/register | Create a new account |
POST | /api/v1/auth/login | Login and receive access + refresh tokens |
POST | /api/v1/auth/refresh | Refresh an expired access token |
POST | /api/v1/auth/forgot-password | Request a password reset email |
POST | /api/v1/auth/reset-password | Reset password with a token from email |
GET | /health | Health check |
GET | /api/v1/features | Feature flags (GIF search enabled, provider name) |
Users
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/users/me | Get current user profile |
PATCH | /api/v1/users/me | Update profile (display name, about me, status) |
POST | /api/v1/users/me/avatar | Upload avatar image |
POST | /api/v1/users/me/change-password | Change password |
POST | /api/v1/users/me/change-email | Change email address |
DELETE | /api/v1/users/me | Delete account |
GET | /api/v1/users/me/read-states | List unread channel states |
GET | /api/v1/users/{userID} | Get a user's public profile |
GET | /api/v1/users/search?q=... | Search users (shared server members) |
Servers
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/servers | Create a server |
GET | /api/v1/servers | List servers you've joined |
GET | /api/v1/servers/{id} | Get server details |
PATCH | /api/v1/servers/{id} | Update server settings |
DELETE | /api/v1/servers/{id} | Delete server (owner only) |
POST | /api/v1/servers/{id}/icon | Upload server icon |
POST | /api/v1/servers/{id}/join | Join a server |
DELETE | /api/v1/servers/{id}/leave | Leave a server |
GET | /api/v1/servers/{id}/members | List server members |
PATCH | /api/v1/servers/{id}/members/{userID}/nickname | Update a member's nickname |
POST | /api/v1/servers/{id}/invites | Create an invite link |
GET | /api/v1/servers/{id}/invites | List server invites |
GET | /api/v1/servers/{id}/audit-log | View audit log |
Channels & Categories
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/servers/{id}/channels | List channels in a server |
POST | /api/v1/servers/{id}/channels | Create a channel |
PATCH | /api/v1/servers/{id}/channels/{channelID} | Update channel (name, topic) |
DELETE | /api/v1/servers/{id}/channels/{channelID} | Delete a channel |
PUT | /api/v1/servers/{id}/channels/reorder | Reorder channels (drag-and-drop) |
GET | /api/v1/servers/{id}/categories | List categories |
POST | /api/v1/servers/{id}/categories | Create a category |
PATCH | /api/v1/servers/{id}/categories/{categoryID} | Update a category |
DELETE | /api/v1/servers/{id}/categories/{categoryID} | Delete a category |
Messages
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/channels/{channelID}/messages | Get messages (cursor-based pagination) |
POST | /api/v1/channels/{channelID}/messages | Send a text message |
POST | /api/v1/channels/{channelID}/messages/upload | Send a message with file attachments |
PUT | /api/v1/channels/{channelID}/messages/{messageID} | Edit a message |
DELETE | /api/v1/channels/{channelID}/messages/{messageID} | Delete a message |
POST | /api/v1/channels/{channelID}/import | Bulk import messages (migration tool) |
PUT | /api/v1/channels/{channelID}/messages/{messageID}/reactions/{emoji} | Add a reaction |
DELETE | /api/v1/channels/{channelID}/messages/{messageID}/reactions/{emoji} | Remove a reaction |
PUT | /api/v1/channels/{channelID}/pins/{messageID} | Pin a message (max 50 per channel) |
DELETE | /api/v1/channels/{channelID}/pins/{messageID} | Unpin a message |
GET | /api/v1/channels/{channelID}/pins | List pinned messages |
POST | /api/v1/channels/{channelID}/ack | Mark channel as read |
GET | /api/v1/uploads/* | Serve uploaded files (public) |
Direct Messages
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/dm | Create or get a DM channel (1:1 or group) |
GET | /api/v1/dm | List your DM channels |
GET | /api/v1/dm/{channelID} | Get a DM channel |
POST | /api/v1/dm/{channelID}/close | Close (hide) a DM channel |
Roles & Permissions
Permissions use a bitfield system with per-channel overrides. The computed permissions endpoint returns a member's effective permissions after applying role hierarchy and channel-level overrides.
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/servers/{id}/roles | List roles |
POST | /api/v1/servers/{id}/roles | Create a role |
PATCH | /api/v1/servers/{id}/roles/{roleID} | Update role (name, color, permissions) |
DELETE | /api/v1/servers/{id}/roles/{roleID} | Delete a role |
POST | /api/v1/servers/{id}/roles/{roleID}/assign | Assign a role to a member |
POST | /api/v1/servers/{id}/roles/{roleID}/remove | Remove a role from a member |
GET | /api/v1/servers/{id}/permissions | Get a member's computed permissions |
Moderation
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/servers/{id}/kick/{targetID} | Kick a member |
POST | /api/v1/servers/{id}/bans/{targetID} | Ban a member |
DELETE | /api/v1/servers/{id}/bans/{targetID} | Unban a member |
GET | /api/v1/servers/{id}/bans | List banned members |
POST | /api/v1/servers/{id}/timeout/{targetID} | Timeout a member (with duration) |
Invites
| Method | Endpoint | Description |
|---|---|---|
DELETE | /api/v1/invites/{inviteID} | Delete an invite |
POST | /api/v1/invites/{code}/join | Join a server via invite code |
Bots API
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/servers/{id}/bots | Create a bot |
GET | /api/v1/servers/{id}/bots | List bots in a server |
GET | /api/v1/servers/{id}/bots/{botID} | Get a bot |
PATCH | /api/v1/servers/{id}/bots/{botID} | Update a bot |
DELETE | /api/v1/servers/{id}/bots/{botID} | Delete a bot |
POST | /api/v1/servers/{id}/bots/{botID}/regenerate-token | Regenerate bot token |
Webhooks API
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /api/v1/servers/{id}/webhooks | Yes | Create a webhook |
GET | /api/v1/servers/{id}/webhooks | Yes | List webhooks |
GET | /api/v1/servers/{id}/webhooks/{webhookID} | Yes | Get a webhook |
PATCH | /api/v1/servers/{id}/webhooks/{webhookID} | Yes | Update a webhook |
DELETE | /api/v1/servers/{id}/webhooks/{webhookID} | Yes | Delete a webhook |
POST | /api/v1/webhooks/{webhookID}/{token} | No | Execute webhook (send a message) |
Interactions API
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /api/v1/servers/{id}/bots/{botID}/commands | Bot | Register a slash command |
GET | /api/v1/servers/{id}/bots/{botID}/commands | Bot | List bot's commands |
PATCH | /api/v1/servers/{id}/bots/{botID}/commands/{commandID} | Bot | Update a command |
DELETE | /api/v1/servers/{id}/bots/{botID}/commands/{commandID} | Bot | Delete a command |
GET | /api/v1/servers/{id}/commands | Yes | List all commands in a server |
POST | /api/v1/servers/{id}/interactions | Yes | Execute a slash command |
POST | /api/v1/interactions/{token}/callback | No | Bot responds to an interaction |
Search, GIFs & Utilities
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/search?q=...&serverId=...&channelId=... | Full-text message search |
GET | /api/v1/gifs/search?q=...&limit=20 | Search GIFs (Tenor or Giphy) |
GET | /api/v1/gifs/trending?limit=20 | Trending GIFs |
GET | /api/v1/unfurl?url=... | Resolve Tenor/Giphy share URLs to media URLs |
GET | /api/v1/docs | Interactive API documentation |
GET | /api/v1/docs/openapi.yaml | OpenAPI 3.0 specification |
WebSocket
Real-time events are delivered via WebSocket at /api/v1/ws. Connect with a valid access token or bot token. The connection supports heartbeat/keepalive and automatic reconnection.
const ws = new WebSocket( `wss://your-instance.com/api/v1/ws?token=${accessToken}` ); ws.onmessage = (event) => { const data = JSON.parse(event.data); console.log(data.type, data.payload); };
Events include message create/update/delete, typing indicators, presence changes, member updates, channel changes, interaction dispatches, and more.
Troubleshooting
Container won't start
Check logs with docker compose logs bastion. Common issues: missing BASTION_JWT_SECRET, database not ready yet (restart with docker compose restart bastion), or port already in use.
WebSocket connection fails
Ensure your reverse proxy passes WebSocket upgrade headers. See the Reverse Proxy section for correct Caddy and Nginx configuration.
File uploads not working
Ensure the BASTION_UPLOAD_DIR exists and is writable by the container. Check BASTION_UPLOAD_MAX_SIZE if large files are rejected.
Database migration errors
Migrations run automatically on startup. If they fail, check that your PostgreSQL version is 14+ and the database user has CREATE TABLE permissions.
Password reset emails not sending
Configure either BASTION_SMTP_* or BASTION_MAILGUN_* environment variables. Some VPS providers (e.g. Linode) block outbound SMTP on port 587 — use Mailgun in that case. Check BASTION_DOMAIN is set correctly for the reset link URL.
GIF picker not showing
Set either BASTION_TENOR_API_KEY or BASTION_GIPHY_API_KEY in your environment. The client checks /api/v1/features to determine if GIF search is available.
Bot returns 503 BOT_OFFLINE
Your bot must have an active WebSocket connection to receive and respond to slash command interactions. Ensure your bot process is running and connected to /api/v1/ws.