Skip to content
Closed
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
2 changes: 2 additions & 0 deletions deploy/stc/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Deploy files run on Linux; a stray CR breaks env parsing and Caddyfile directives
deploy/stc/** text eol=lf
4 changes: 4 additions & 0 deletions deploy/stc/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{$CHAT_DOMAIN} {
encode zstd gzip
reverse_proxy traefik:80
}
60 changes: 60 additions & 0 deletions deploy/stc/README-STC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# STC Tailchat deployment — netcup-vps-01

Production chat for STC Worldwide, served publicly at `https://$CHAT_DOMAIN`.

- **Host:** netcup-vps-01 (152.53.82.15 public / 100.66.129.67 tailnet), Debian 13, root over key-only SSH
- **Location on host:** `/opt/tailchat/`
- **Image:** `moonrailgun/tailchat:1.11.12` (pinned; official upstream image — the STC fork is source custody, not the build source)
- **Topology:** Caddy (public 80/443, Let's Encrypt) → traefik (internal path router) → tailchat services. MongoDB/Redis/MinIO are internal-only.

## Deploy / redeploy

```sh
cd /opt/tailchat
docker compose pull
docker compose up -d
```

First-time setup: copy `docker-compose.env.example` → `docker-compose.env`, fill every
blank value, `chmod 600 docker-compose.env`, `ln -s docker-compose.env .env`.
DNS A record for `$CHAT_DOMAIN` → 152.53.82.15 must exist before first start or
Let's Encrypt issuance fails (Caddy retries automatically once DNS resolves).

## Verify what is INSTALLED, not what is checked out

The compose file in the git fork is not what runs — `/opt/tailchat/docker-compose.yml`
on the VPS is. After changing the fork, copy the files over and `docker compose up -d`,
then check `docker compose ps` and `docker inspect --format '{{.Config.Image}}' <ctr>`.

## Update to a newer Tailchat

1. Check upstream releases / Docker Hub tags (`moonrailgun/tailchat`).
2. Edit the pinned tag in `deploy/stc/docker-compose.yml` (all four app services), PR it.
3. Copy to VPS, `docker compose pull && docker compose up -d`.
4. Smoke-test: login, send message, upload image, `/admin/`.

## Registration control

`EMAIL_VERIFY=true` gates signup on a verified email. To freeze signups entirely once
the org is onboarded, add `DISABLE_USER_REGISTER=true` to `docker-compose.env` and
`docker compose up -d` (recreates app containers).

## Backups

Nightly cron on the VPS (`/etc/cron.d/tailchat-backup`) dumps MongoDB and tars the
MinIO volume into `/root/backups/tailchat/`, keeping 14 days.

Restore: stop the stack, `mongorestore` the dump into the `mongo` container, untar the
MinIO volume back into `tailchat-server_storage`, start the stack.

## Ports exposed publicly

Only Caddy's 80/443 (+443/udp for HTTP/3). Docker-published ports bypass the host's
default-deny nftables — never add a `ports:` mapping to any other service unless it is
bound to `127.0.0.1` or the tailnet IP.

## Admin panel

`https://$CHAT_DOMAIN/admin/` (trailing slash required). Credentials: `ADMIN_USER` /
`ADMIN_PASS` from `docker-compose.env`. The newer `admin-next` app is NOT deployed —
it postdates the 1.11.12 image.
41 changes: 41 additions & 0 deletions deploy/stc/docker-compose.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# STC Tailchat environment — copy to docker-compose.env on the VPS, chmod 600.
# Then: ln -s docker-compose.env .env
# (compose reads .env for ${...} interpolation; containers read docker-compose.env)
# NEVER commit the real file.

LOGGER=true
LOGLEVEL=info
SERVICEDIR=services

TRANSPORTER=redis://redis:6379
REDIS_URL=redis://redis:6379
MONGO_URL=mongodb://mongo/tailchat

# Public hostname. CHAT_DOMAIN drives Caddy TLS; API_URL must be https://<same host>.
CHAT_DOMAIN=chat.example.com
API_URL=https://chat.example.com

# JWT signing key — generate: openssl rand -hex 24. Never leave default.
SECRET=

# Registration requires a verified email address.
EMAIL_VERIFY=true
# Example: SMTP_SENDER="STC Chat" chat@stcbas.com
SMTP_SENDER=
# Example: SMTP_URI=smtp://user:password@smtp.example.com/?pool=true
SMTP_URI=

# MinIO file storage (internal). MINIO_USER/PASS are also injected as the
# MinIO root credentials via compose interpolation — one source of truth.
MINIO_URL=minio:9000
MINIO_USER=tailchat
MINIO_PASS=

# 25 MB upload limit (default is 1 MB)
FILE_LIMIT=26214400

PROMETHEUS=1

# Admin panel at /admin/
ADMIN_USER=stcadmin
ADMIN_PASS=
155 changes: 155 additions & 0 deletions deploy/stc/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# STC Worldwide production deployment — chat.<domain> on netcup-vps-01
# Adapted from /docker-compose.yml + /docker/admin.yml upstream.
# Differences from upstream:
# - pinned published image instead of local build
# - Caddy terminates public TLS (80/443); traefik stays internal (loopback debug bind only)
# - MinIO root credentials come from .env (symlink to docker-compose.env), not hard-coded
# - legacy admin only; admin-next postdates the 1.11.12 image and is not in it
#
# The ONLY publicly exposed ports are Caddy's 80/443. Docker published ports
# bypass the host's default-deny nftables, so every other service must stay
# unpublished (loopback binds are fine).

services:
service-core:
image: moonrailgun/tailchat:1.11.12
restart: unless-stopped
env_file: docker-compose.env
environment:
SERVICES: core/gateway,core/user/*.service.js,core/group/*.service.js,core/chat/*.service.js,core/file,core/plugin/registry,core/config
PORT: 3000
depends_on:
- mongo
- redis
- minio
healthcheck:
test: ["CMD", "wget", "localhost:3000/health", "-q", "-O", "-"]
interval: 30s
timeout: 5s
retries: 5
start_period: 15s
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-gw.rule=PathPrefix(`/`)"
- "traefik.http.services.api-gw.loadbalancer.server.port=3000"
networks:
- internal

service-openapi:
image: moonrailgun/tailchat:1.11.12
restart: unless-stopped
env_file: docker-compose.env
environment:
SERVICES: openapi/app,openapi/bot,openapi/integration,openapi/oidc/oidc
OPENAPI_PORT: 3003
OPENAPI_UNDER_PROXY: "true"
depends_on:
- mongo
- redis
- minio
labels:
- "traefik.enable=true"
- "traefik.http.routers.openapi-oidc.rule=PathPrefix(`/open`)"
- "traefik.http.services.openapi-oidc.loadbalancer.server.port=3003"
networks:
- internal

service-all-plugins:
image: moonrailgun/tailchat:1.11.12
restart: unless-stopped
env_file: docker-compose.env
environment:
SERVICEDIR: plugins
depends_on:
- mongo
- redis
- minio
networks:
- internal

tailchat-admin:
image: moonrailgun/tailchat:1.11.12
restart: unless-stopped
env_file: docker-compose.env
depends_on:
- mongo
- redis
labels:
- "traefik.enable=true"
- "traefik.http.routers.admin.rule=PathPrefix(`/admin`)"
- "traefik.http.routers.admin.priority=50"
- "traefik.http.services.admin.loadbalancer.server.port=3000"
networks:
- internal
command: pnpm start:admin

mongo:
image: mongo:4
restart: on-failure
volumes:
- data:/data/db
networks:
- internal

redis:
image: redis:alpine
restart: on-failure
networks:
- internal

minio:
image: minio/minio
restart: on-failure
networks:
- internal
environment:
MINIO_ROOT_USER: ${MINIO_USER:?set MINIO_USER in .env (symlink docker-compose.env)}
MINIO_ROOT_PASSWORD: ${MINIO_PASS:?set MINIO_PASS in .env (symlink docker-compose.env)}
volumes:
- storage:/data
command: minio server /data --console-address ":9001"

traefik:
image: traefik:v3.7.8
restart: unless-stopped
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
# Only Caddy can reach this entrypoint (internal network); headers it
# forwards are trustworthy in this topology.
- "--entryPoints.web.forwardedHeaders.insecure"
ports:
- 127.0.0.1:11000:80 # host-local debug access only
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- internal

caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
- traefik
ports:
- 80:80
- 443:443
- 443:443/udp
environment:
CHAT_DOMAIN: ${CHAT_DOMAIN:?set CHAT_DOMAIN in .env (symlink docker-compose.env)}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- internal

networks:
internal:
name: tailchat-internal

volumes:
data:
storage:
caddy_data:
caddy_config:
Loading