From 22b689caf924784ae0c53ae543f0bd2f55ae61aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 11:38:55 +0000 Subject: [PATCH] fix(docker): exit container when a process crashes; pass all env vars through compose Self-hosted install verification revealed two issues and doc gaps: - entrypoint.sh relied on `wait -n pid1 pid2`, but busybox ash waits for ALL listed pids (-n is not honoured). If the API crashed (e.g. missing SERVER_ENCRYPTION_KEY) the container stayed "running" but broken instead of exiting so Docker's restart policy could surface the failure. Replaced with a kill -0 polling loop that exits as soon as either process dies. - docker-compose.yml only whitelisted part of the documented env vars. Branding (APP_DESCRIPTION, APP_PRIMARY_COLOR, APP_LOGO_URL, ...), API_KEY/API_KEY_N, CAP_*, TRUSTED_PROXIES, MAX_TEXT_SIZE, CLEANUP_INTERVAL_MS and DEBUG set in .env were silently ignored. All documented variables are now passed through (empty values are filtered by the API, so the defaults stay safe). Docs: - README: one-click deploy table (Render button, Coolify, Portainer, Synology), clearer PORT semantics, troubleshooting link. - docs/self-hosting.md: new Troubleshooting section (restart loop, .env changes needing --force-recreate, port conflicts, bind-mount permissions, unhealthy container, proxy upload limits), Deploy-to-Render button, note that Render disks require a paid plan. Verified against ghcr.io/largerio/secret:latest with Docker: quick start, docker run one-liner, Portainer-style stack deploy (no .env file), note create/read, persistence across restarts, graceful shutdown, and crash-exit behaviour with the patched entrypoint. https://claude.ai/code/session_01NrzYX4SD2wdrJetorUU48S --- README.md | 23 +++++++++++--- docker-compose.yml | 16 ++++++++++ docs/self-hosting.md | 72 +++++++++++++++++++++++++++++++++++++++++++- entrypoint.sh | 16 ++++++++-- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3e60825..6c2bc18 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,21 @@ openssl rand -base64 32 # → SERVER_ENCRYPTION_KEY= docker compose up -d # pulls ghcr.io/largerio/secret:latest ``` +Open `http://localhost:3000`. API documentation is available at `/api/v1/docs` ([Scalar](https://scalar.com/)). +If something doesn't work, run `docker compose logs -f` — see +[Troubleshooting](docs/self-hosting.md#troubleshooting). + +**One-click / platform deploys:** + +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/largerio/secret) + +| Platform | How | +|----------|-----| +| **Render** | Click the button above ([guide](docs/self-hosting.md#railway--render-paas)) | +| **Coolify** | New Resource → Docker Image → `ghcr.io/largerio/secret:latest` ([guide](docs/self-hosting.md#coolify)) | +| **Portainer** | Stacks → paste `docker-compose.yml` ([guide](docs/self-hosting.md#portainer)) | +| **Synology NAS** | Container Manager project ([guide](docs/self-hosting.md#synology-nas-dsm-7--container-manager)) | + **From source (for contributors):** ```bash @@ -90,9 +105,7 @@ openssl rand -base64 32 # → set SERVER_ENCRYPTION_KEY in .env docker compose up -d ``` -Open `http://localhost:3000`. API documentation is available at `/api/v1/docs` ([Scalar](https://scalar.com/)). - -For Synology NAS, VPS, reverse proxy and backup instructions, see the +For Synology NAS, VPS, reverse proxy, troubleshooting and backup instructions, see the [Self-Hosting guide](docs/self-hosting.md). ## SDK @@ -145,7 +158,7 @@ All settings via environment variables. See [.env.example](.env.example) for the | `SERVER_ENCRYPTION_KEY` | — | **Required.** AES-256-GCM key (32 bytes, base64) | | `APP_NAME` | `Secret` | Application name | | `APP_URL` | `http://localhost:3000` | Public URL | -| `APP_PRIMARY_COLOR` | `#6366f1` | Brand color | +| `APP_PRIMARY_COLOR` | `#6366f1` | Brand color (see `.env.example` for logo, favicon, footer…) | | `MAX_FILE_SIZE` | `10485760` | Max file size in bytes (10 MB) | | `MAX_FILES_PER_NOTE` | `10` | Max files per note | | `MAX_EXPIRY` | `604800` | Max expiry in seconds (default: 7 days, max: 30 days) | @@ -153,7 +166,7 @@ All settings via environment variables. See [.env.example](.env.example) for the | `API_KEY_1`, `API_KEY_2`… | — | Multiple API keys (optional) | | `CHUNK_SIZE` | `4194304` | Chunk size for large uploads (4 MB) | | `MAX_CHUNKED_FILE_SIZE` | `524288000` | Max chunked upload size (500 MB) | -| `PORT` | `3000` | Server port | +| `PORT` | `3000` | Host port the app is published on (inside the container the web server always listens on 3000, the API on 3001) | > **Warning:** Never change `SERVER_ENCRYPTION_KEY` after deployment — all existing notes become unreadable. diff --git a/docker-compose.yml b/docker-compose.yml index 2d01279..333cacf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,8 +14,15 @@ services: - SERVER_ENCRYPTION_KEY=${SERVER_ENCRYPTION_KEY} - APP_URL=${APP_URL:-http://localhost:3000} - APP_NAME=${APP_NAME:-Secret} + - APP_DESCRIPTION=${APP_DESCRIPTION:-Secure zero-knowledge note and file sharing} + - APP_PRIMARY_COLOR=${APP_PRIMARY_COLOR:-#6366f1} + - APP_LOGO_URL=${APP_LOGO_URL:-} + - APP_FAVICON_URL=${APP_FAVICON_URL:-} + - APP_FOOTER_TEXT=${APP_FOOTER_TEXT:-} + - APP_OG_IMAGE_URL=${APP_OG_IMAGE_URL:-} - MAX_FILE_SIZE=${MAX_FILE_SIZE:-10485760} - MAX_FILES_PER_NOTE=${MAX_FILES_PER_NOTE:-10} + - MAX_TEXT_SIZE=${MAX_TEXT_SIZE:-102400} - DEFAULT_EXPIRY=${DEFAULT_EXPIRY:-86400} - MAX_EXPIRY=${MAX_EXPIRY:-604800} - BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-105906176} @@ -28,6 +35,15 @@ services: - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID:-} - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY:-} - S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE:-false} + - API_KEY=${API_KEY:-} + - API_KEY_1=${API_KEY_1:-} + - API_KEY_2=${API_KEY_2:-} + - API_KEY_3=${API_KEY_3:-} + - CAP_DIFFICULTY=${CAP_DIFFICULTY:-4} + - CAP_CHALLENGE_COUNT=${CAP_CHALLENGE_COUNT:-50} + - TRUSTED_PROXIES=${TRUSTED_PROXIES:-} + - CLEANUP_INTERVAL_MS=${CLEANUP_INTERVAL_MS:-300000} + - DEBUG=${DEBUG:-0} restart: unless-stopped init: true read_only: true diff --git a/docs/self-hosting.md b/docs/self-hosting.md index da4ef34..8f8c746 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -10,6 +10,7 @@ and runs as a single container. - [Reverse proxy & HTTPS](#reverse-proxy--https) - [Backup & restore](#backup--restore) - [Updating](#updating) +- [Troubleshooting](#troubleshooting) > **Image architecture:** the official image is multi-arch (`linux/amd64` and > `linux/arm64`), so it runs natively on x86 servers as well as ARM hosts — @@ -87,11 +88,17 @@ Coolify gives you automatic HTTPS through its built-in proxy. ### Railway / Render (PaaS) +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/largerio/secret) + Deploy directly from the image `ghcr.io/largerio/secret:latest`. A -[`render.yaml`](../render.yaml) blueprint is included for one-click Render deploys. +[`render.yaml`](../render.yaml) blueprint is included — the button above deploys +it in one click; Render will then prompt you for the two required values +(`SERVER_ENCRYPTION_KEY` and `APP_URL`). - ⚠️ **Attach a persistent disk/volume mounted at `/app/data`** — PaaS filesystems are ephemeral, so without it every redeploy wipes all notes. + The included `render.yaml` already declares this disk. Note that persistent + disks require a **paid** instance type on Render (the free tier has no disks). - Set `APP_URL` to the platform-assigned domain. - Set `SERVER_ENCRYPTION_KEY` yourself — it must be 32 random bytes, base64 encoded (`openssl rand -base64 32`). A platform's generic "random value" @@ -207,3 +214,66 @@ docker image prune -f # clean up old layers ``` Your data lives in the volume, so updates never delete notes. + +--- + +## Troubleshooting + +**First reflex:** look at the logs — every startup error is printed there. + +```bash +docker compose logs -f # or: docker logs -f secret +``` + +### Container keeps restarting / exits immediately + +Almost always a missing or invalid `SERVER_ENCRYPTION_KEY`. The logs will show: + +``` +ERROR: SERVER_ENCRYPTION_KEY is required. +Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +``` + +Generate a key (`openssl rand -base64 32`), set it in `.env` +(`SERVER_ENCRYPTION_KEY=`), then `docker compose up -d`. + +> The key must be exactly **32 bytes encoded as base64** (44 characters ending +> in `=`). A random password or hex string will be rejected. + +### Changed `.env` but nothing happens + +`docker compose up -d` only recreates the container when its configuration +changed. After editing `.env`, force a recreate: + +```bash +docker compose up -d --force-recreate +``` + +### Port 3000 already in use + +Set a different host port in `.env` (e.g. `PORT=8080`), then +`docker compose up -d --force-recreate`. The app will be reachable on +`http://:8080`. + +### "Permission denied" on `/app/data` (bind mounts) + +Happens when you replaced the named volume with a bind-mounted folder. The +container runs as **uid 1001**, so the folder must be writable by it: + +```bash +sudo chown -R 1001:1001 /path/to/your/data +``` + +### Container is `unhealthy` + +Check `docker compose logs` for the underlying error, and verify the health +endpoint from inside the host: + +```bash +curl http://localhost:3000/api/health # should return {"status":"ok"} +``` + +### Uploads fail behind a reverse proxy + +Increase the proxy's request body limit (`client_max_body_size` in Nginx) to at +least `MAX_CHUNKED_FILE_SIZE` — see [Reverse proxy & HTTPS](#reverse-proxy--https). diff --git a/entrypoint.sh b/entrypoint.sh index f7ce2cc..64a2bc7 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -22,8 +22,20 @@ PORT=3000 ORIGIN="${APP_URL:-http://localhost:3000}" \ web_pid=$! # Exit as soon as either process exits, then signal the other and reap both. -wait -n "$api_pid" "$web_pid" -status=$? +# busybox ash has no working `wait -n` (it waits for ALL children), so poll +# instead: if one process dies the container must exit so Docker's restart +# policy and orchestrators see the failure instead of a "running" but broken +# container. +while kill -0 "$api_pid" 2>/dev/null && kill -0 "$web_pid" 2>/dev/null; do + sleep 1 +done + +status=0 +if ! kill -0 "$api_pid" 2>/dev/null; then + wait "$api_pid" || status=$? +else + wait "$web_pid" || status=$? +fi term wait "$api_pid" "$web_pid" 2>/dev/null || true exit "$status"