# 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 : 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: