Files
inkwell/docker-compose.yml
T
bvandeusenandClaude Opus 5.5 edcb5c4c11
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m38s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m5s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Backups: a daily dedupe-friendly tar to a folder or S3, with restore (#5918-#5921)
#5918 "Backup file": an uncompressed tar of per-table JSON Lines (primary-key
order, sorted keys) plus every attachment file, with fixed tar headers, a
dated name, and an atomic write through a dot-named .part file. Unchanged
rows are the same bytes every day, so restic off-site stores only the edits.
No credential ever enters a backup.

#5919 "Backup destinations": a folder, or an S3-compatible bucket through a
stdlib SigV4 put/list client (checked against AWS's published signatures),
with an upload limit. Test destination only lists, so an append-only key works.

#5920 "Backup job": daily at a chosen hour, Back up now, a 1/2/4 h retry
ladder, a one-hour deadline, an advisory lock, the last 30 runs, and an
overdue verdict on Settings -> Backups.

#5921 "Restore": python -m inkwell.restore FILE into an empty instance, an
integration test that backs up and restores, and docs/backups.md with
Fabled Archives as the worked example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:54:51 -04:00

124 lines
5.7 KiB
YAML

# Inkwell — 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 INKWELL_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: Settings →
# Backups → Back up now writes one file holding the notes AND their attachments
# (docs/backups.md). A bare pg_dump does not: attachments are files in the
# `inkwell-data` volume, not rows.
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-inkwell}
# 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:-inkwell}
volumes:
# Which volume this resolves to is set at the bottom of this file, and an
# existing deployment overrides it from .env rather than renaming anything.
- inkwell-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 inkwell` gets you there without it.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-inkwell} -d ${POSTGRES_DB:-inkwell}"]
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 INKWELL_TAG=dev in .env to follow the
# development line instead, or a commit sha to pin exactly.
image: git.fabledsword.com/bvandeusen/inkwell:${INKWELL_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).
INKWELL_DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-inkwell}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-inkwell}
volumes:
# Uploaded attachments. /var/inkwell is fixed in the app (Config.DATA_DIR),
# not configurable — mount it or lose every image on container recreation.
- inkwell-data:/var/inkwell
# Daily backups to a folder (Settings → Backups, docs/backups.md): mount the
# share they should land in. Not needed when they go to an S3 bucket.
# - /mnt/backups/inkwell:/backups
# 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 INKWELL_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:
- "${INKWELL_BIND:-0.0.0.0}:${INKWELL_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:
# Named explicitly so the name is one a person can type, and overridable so a
# deployment that predates the rename to Inkwell keeps the volumes it already has
# (see "Upgrading from ThoughtSync" in .env.example). Pointing these at the wrong
# name does not fail: it starts against an EMPTY database and the old one sits
# there untouched, which looks exactly like data loss.
inkwell-db:
name: ${INKWELL_DB_VOLUME:-inkwell-db}
inkwell-data:
name: ${INKWELL_DATA_VOLUME:-inkwell-data}