The operator asked why `THOUGHTSYNC_BIND` isn't just defaulted to the safe value. Fair question, and the answer exposed that my own advice was incomplete: I told them to set it to 127.0.0.1 without asking where their proxy runs, and for a proxy inside Docker that is the wrong fix. There are three shapes, not two: 1. **LAN, no proxy** — the default. Binds every interface so a phone and a desktop can reach the server. This is why the default is NOT the locked-down value: a server reachable only from the machine it runs on isn't hardened, it's broken, and that is the primary documented use of this app. 2. **Proxy in Docker** — delete the `ports:` block entirely. The proxy reaches the app over the compose network; publishing a host port is a second, unauthenticated way in that bypasses whatever the proxy does about TLS. Safer than 127.0.0.1, because there is no host port to reach even from the host. 3. **Proxy on the host** — `THOUGHTSYNC_BIND=127.0.0.1`. The compose file now spells out all three where the decision is made, and `docs/public-hosting.md` item 4 asks where your proxy runs before telling you what to do, plus how to check: `curl http://<lan-ip>:5000/api/health` from another machine should NOT answer once you're proxied. No default changed. Changing it would silently break every LAN install on the next `docker compose pull` — the phone would just stop syncing, with nothing saying why.
118 lines
5.3 KiB
YAML
118 lines
5.3 KiB
YAML
# ThoughtSync — PRODUCTION stack (app + Postgres).
|
|
#
|
|
# This is the default compose file: `docker compose up -d` runs a real deployment
|
|
# from the published image. Development lives in docker-compose.dev.yml (hot-reload,
|
|
# builds from source).
|
|
#
|
|
# cp .env.example .env # then set POSTGRES_PASSWORD
|
|
# docker compose up -d
|
|
#
|
|
# Per family rule 12 the agent does NOT start this — run it yourself.
|
|
#
|
|
# Upgrades: docker compose pull && docker compose up -d
|
|
# Rollback: set THOUGHTSYNC_TAG to a commit sha in .env, then the same two commands.
|
|
# Every push publishes an immutable :<sha> image for exactly this.
|
|
#
|
|
# Schema migrations run automatically at container start (see the Dockerfile CMD),
|
|
# so an upgrade is just a pull and a restart. Take a backup first anyway:
|
|
#
|
|
# docker compose exec -T db pg_dump -U thoughtsync thoughtsync > backup.sql
|
|
#
|
|
# Attachments are files, not rows — they live in the `thoughtsync-data` volume and
|
|
# a pg_dump does NOT contain them. Back up both or you'll restore notes whose images
|
|
# are gone.
|
|
|
|
services:
|
|
db:
|
|
image: postgres:16-alpine
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER:-thoughtsync}
|
|
# No default on purpose. A production compose that ships a known password is
|
|
# how self-hosted databases end up in search engines; compose fails fast here
|
|
# instead, with the message below.
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env — see .env.example}
|
|
POSTGRES_DB: ${POSTGRES_DB:-thoughtsync}
|
|
volumes:
|
|
# Volume names kept from the previous compose file so an existing deployment
|
|
# upgrades in place. Renaming them would silently start against an empty
|
|
# database while the old one sat there, orphaned and looking like data loss.
|
|
- thoughtsync-db:/var/lib/postgresql/data
|
|
# Deliberately NOT published to the host. The app reaches Postgres over the
|
|
# compose network; exposing 5432 only widens the attack surface. If you need
|
|
# psql, `docker compose exec db psql -U thoughtsync` gets you there without it.
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-thoughtsync} -d ${POSTGRES_DB:-thoughtsync}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 30s
|
|
logging: &logging
|
|
driver: json-file
|
|
options:
|
|
# Unbounded container logs are a slow-motion disk-full outage on a
|
|
# long-running self-hosted box.
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
|
|
app:
|
|
# :latest tracks `main`. Set THOUGHTSYNC_TAG=dev in .env to follow the
|
|
# development line instead, or a commit sha to pin exactly.
|
|
image: git.fabledsword.com/bvandeusen/thoughtsync:${THOUGHTSYNC_TAG:-latest}
|
|
restart: unless-stopped
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
# The only required application setting. Everything else a person might want
|
|
# to tune lives in the admin Settings UI, backed by the database (rule 25).
|
|
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-thoughtsync}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-thoughtsync}
|
|
volumes:
|
|
# Uploaded attachments. /var/thoughtsync is fixed in the app (Config.DATA_DIR),
|
|
# not configurable — mount it or lose every image on container recreation.
|
|
- thoughtsync-data:/var/thoughtsync
|
|
# WHERE THE APP IS REACHABLE FROM. Three shapes, and the right answer is
|
|
# different for each — the default serves the first.
|
|
#
|
|
# 1. LAN, no proxy (the default). Binds every interface so your phone and your
|
|
# desktop can reach the server. This is what makes a self-hosted install work
|
|
# out of the box, and it is why the default is NOT the locked-down value: a
|
|
# server only reachable from the machine it runs on is not hardened, it is
|
|
# broken.
|
|
#
|
|
# 2. Reverse proxy in Docker, on this network (Traefik discovering the container,
|
|
# an nginx container, etc). DELETE the `ports:` block below entirely. The proxy
|
|
# reaches the app over the compose network without any port being published,
|
|
# and publishing one is a second, unauthenticated way in that bypasses the
|
|
# proxy — including whatever the proxy is doing about TLS and auth.
|
|
#
|
|
# 3. Reverse proxy on the HOST (not in Docker). Set THOUGHTSYNC_BIND=127.0.0.1 in
|
|
# .env, so the port exists but only the host itself can reach it.
|
|
#
|
|
# If you are exposing this to the internet, you want 2 or 3. Leaving it at 1
|
|
# means the app is reachable directly on port 5000, past everything your proxy
|
|
# does.
|
|
ports:
|
|
- "${THOUGHTSYNC_BIND:-0.0.0.0}:${THOUGHTSYNC_PORT:-5000}:5000"
|
|
healthcheck:
|
|
# python rather than curl: the runtime image is python:3.12-slim and carries no
|
|
# HTTP client binary. Hits the app's own /api/health.
|
|
test:
|
|
- CMD
|
|
- python
|
|
- -c
|
|
- |
|
|
import sys, urllib.request
|
|
sys.exit(0 if urllib.request.urlopen("http://127.0.0.1:5000/api/health", timeout=5).status == 200 else 1)
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 5
|
|
# Generous: the container waits for Postgres and runs migrations before it
|
|
# serves anything, and a first boot builds the whole schema.
|
|
start_period: 60s
|
|
logging: *logging
|
|
|
|
volumes:
|
|
thoughtsync-db:
|
|
thoughtsync-data:
|