Skip to content
Merged
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
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ openssl rand -base64 32 # → SERVER_ENCRYPTION_KEY=<output>
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
Expand All @@ -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
Expand Down Expand Up @@ -145,15 +158,15 @@ 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) |
| `API_KEY` | — | API key for SDK clients (optional) |
| `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.

Expand Down
16 changes: 16 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down
72 changes: 71 additions & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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=<output>`), 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://<host>: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).
16 changes: 14 additions & 2 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"