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
6 changes: 5 additions & 1 deletion docs/guides/api-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,13 @@ sudo simpledeploy apikey create --name ci-deploy --user-id 1
curl -X POST https://manage.example.com/api/apikeys \
-H "Authorization: Bearer $SD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"ci-deploy"}'
-d '{"name":"ci-deploy","expires_at":"2026-12-31T23:59:59Z"}'
# {"id":3,"name":"ci-deploy","key":"sd_a1b2c3..."}
```

`expires_at` is optional (omit for keys that never expire). Past dates are
rejected. `GET /api/apikeys` returns `id`, `name`, `created_at`,
`expires_at`, and `last_used_at` so operators can spot stale keys.
</TabItem>
</Tabs>

Expand Down
17 changes: 16 additions & 1 deletion docs/guides/users-roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,23 @@ curl -X DELETE https://manage.example.com/api/users/2 \

This also revokes all their API keys.

## Session invalidation

JWT cookies carry a `tv` (token version) claim that is bumped server-side on:

- **Logout** — the cookie is invalidated immediately, not just cleared.
- **Password change** — every other session for that user is signed out.
- **Role change** — promotions and demotions take effect right away,
not 24 hours later when the cookie expires.

If a session is suspected compromised, force-rotating the user's password
or role kills the existing JWT cookie even if the attacker holds it.

<Aside>
Account lockout kicks in after 10 failed logins. See [Security hardening](/operations/security-hardening/) for the backoff schedule.
Account lockout kicks in after 10 failed logins per (user, IP) tuple. The
login endpoint is rate-limited to 10 requests/minute per IP. See
[Security hardening](/operations/security-hardening/) for the backoff
schedule.
</Aside>

See also: [API keys](/guides/api-keys/), [Audit log](/guides/audit-log/).
4 changes: 4 additions & 0 deletions docs/operations/disaster-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ docker run --rm -v myapp_data:/restore -v $(pwd):/backup alpine \
tar xzf /backup/myapp-2026-04-15.tar.gz -C /restore
```

<Aside type="caution">
SimpleDeploy's own volume and SQLite restore endpoints (`POST /api/apps/{slug}/backups/upload-restore`) reject tar archives that contain absolute paths, `..` segments, symlinks, hardlinks, or device entries. They also cap gzip decompression at 8 GiB and run no more than 4 concurrent restores at a time. Archives produced by the matching `Backup` step pass these checks; bring-your-own tarballs may need to be repacked without symlinks.
</Aside>

### Step 8: Verify

- Dashboard loads
Expand Down
31 changes: 25 additions & 6 deletions docs/operations/security-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,41 @@ Before going to production:

### JWT Sessions

- Session tokens are signed JWTs with 24-hour expiry
- The signing key is derived from `master_secret` in your config
- If `master_secret` is not set, a random secret is generated per process (sessions won't survive restarts)
- Cookies are set with `HttpOnly`, `Secure`, `SameSite=Strict`, and `MaxAge=86400`
- Session tokens are signed JWTs (HS256) with 24-hour expiry. The signing
key is HKDF-SHA256-derived from `master_secret` with purpose label
`simpledeploy-jwt-v1` so it is domain-separated from credential
encryption and API-key HMAC.
- Tokens carry `iss=simpledeploy`, `aud=simpledeploy-dashboard`, and a
per-user `tv` (token version) claim. Bumping `tv` server-side
invalidates all outstanding JWTs for that user.
- `tv` is bumped on logout, password change, and role change — a stolen
cookie cannot outlive any of those events.
- Cookies are `HttpOnly`, `Secure` (when TLS), `SameSite=Strict`, `MaxAge=86400`.

### API Keys

- API keys use the `sd_` prefix followed by 64 hex characters (32 bytes of entropy)
- Keys are hashed with **HMAC-SHA256** using your `master_secret` before storage
- Even if the database is stolen, keys cannot be recovered without the master secret
- Keys support optional expiry dates; expired keys are rejected at the middleware level
- Keys support optional `expires_at` (set on create); expired keys are rejected at the middleware level
- `last_used_at` is updated lazily on every successful auth so operators can spot stale keys
- The plaintext key is shown exactly once at creation and never stored

### Login Rate Limit

The login endpoint has its own rate limiter, capped at **10 requests per
minute per client IP**. Login abuse cannot deplete the global request
budget and vice versa. `trusted_proxies` accepts CIDR ranges
(e.g. `10.0.0.0/8`); without a matching entry the proxy IP is treated as
the client.

### Account Lockout

After 10 failed login attempts, the account is temporarily locked with progressive backoff:
After 10 failed login attempts per (username, IP) tuple, the account is
temporarily locked with progressive backoff. Locking is per-IP-per-user
so an attacker on one IP cannot DoS a victim's login from a different
IP. A locked-out attempt returns the same `401 invalid credentials` as
a wrong password, eliminating an enumeration tell.

| Failures over threshold | Lockout duration |
|------------------------|-----------------|
Expand Down
36 changes: 36 additions & 0 deletions docs/reference/compose-labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,42 @@ If `simpledeploy.port` is not set, SimpleDeploy uses the first port mapping it f

Endpoint services no longer need to publish host ports to be reachable. SimpleDeploy auto-attaches them to a shared `simpledeploy-public` Docker network and reverse-proxies over that. `ports:` still works and, when present, takes precedence over the shared-network path.

### Published port loopback rewrite

`ports: "8080:80"` style mappings are rewritten at deploy time to
`127.0.0.1:8080:80` so the published port is reachable only from the host
itself. Caddy still proxies external traffic to the same upstream, but
this prevents an unauthenticated attacker from reaching the app raw on
:8080 and bypassing per-app `simpledeploy.access.allow` and
`simpledeploy.ratelimit.*` controls.

Operator-explicit interface bindings (`"0.0.0.0:8080:80"`,
`"127.0.0.1:9090:90"`, `"[::1]:5432:5432"`) are preserved verbatim. To
disable the rewrite globally, set `SIMPLEDEPLOY_DISABLE_PORT_LOOPBACK=true`.

### Compose security validation

Compose files are rejected at deploy time (and by the reconciler watcher
on disk) if they declare any of the following container-escape vectors:

- `privileged: true`
- `network_mode: host`, `pid: host`, `pid: container:*`, `pid: service:*`
- `ipc: host`, `userns_mode: host`, `cgroup: host`
- Dangerous capabilities via `cap_add`: `ALL`, `SYS_ADMIN`, `SYS_PTRACE`,
`SYS_MODULE`, `SYS_RAWIO`, `SYS_BOOT`, `SYS_TIME`, `NET_ADMIN`,
`NET_RAW`, `DAC_READ_SEARCH`, `DAC_OVERRIDE`, `BPF`, `PERFMON`, `MKNOD`
(with or without the `CAP_` prefix).
- `security_opt`: `apparmor=unconfined`, `seccomp=unconfined`,
`label=disable`, `systempaths=unconfined`, `no-new-privileges=false`.
- `devices` (any non-empty list).
- `volumes_from` (any non-empty list).
- Bind mounts of `/etc`, `/proc`, `/sys`, `/dev`, `/var/run/docker.sock`,
`/root`, `/var/lib/docker`, `/boot`, `/lib/modules`, `/run`, `/`,
or any path containing `..`.
- Top-level volumes with `driver_opts` of the form
`type: none, o: bind, device: /host/path` (the host-bind shim) when the
device path falls in the bind-source deny list.

## Access Control Labels

See [IP access control](/guides/access-control/) for the full guide.
Expand Down
8 changes: 7 additions & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ http_listen_addr: ":80"
# Management API + dashboard port
management_port: 8443

# Management bind address. Default "127.0.0.1" keeps the dashboard local-only;
# front it with Caddy on a manage.<domain> route, or set to "" (or "0.0.0.0")
# to bind every interface (legacy behavior, exposes plain HTTP).
management_addr: "127.0.0.1"

# Management domain (for TLS cert)
domain: manage.example.com

Expand Down Expand Up @@ -98,8 +103,9 @@ git_sync:
| `data_dir` | string | `/var/lib/simpledeploy` | Database and backup storage |
| `apps_dir` | string | `/etc/simpledeploy/apps` | Watched directory for compose files |
| `listen_addr` | string | `:443` | Reverse proxy listen address (HTTPS) |
| `http_listen_addr` | string | `""` | Optional plain-HTTP listener that 308-redirects to HTTPS. Ignored when `tls.mode: off`. |
| `http_listen_addr` | string | `:80` (when `tls.mode` is `auto` or `local`) | Plain-HTTP listener that 308-redirects to HTTPS. Set explicitly to `""` to disable. Ignored when `tls.mode: off`. |
| `management_port` | int | `8443` | Management API port |
| `management_addr` | string | `127.0.0.1` | Management API bind address. Default keeps the dashboard local-only. Set to `""` (or `0.0.0.0`) to expose on every interface. |
| `domain` | string | - | Management UI domain |
| `public_host` | string | `""` | Server hostname/IP used for sslip.io auto-domains in template Quick test mode. Editable at runtime. |
| `tls.mode` | string | `auto` | TLS mode: `auto`, `custom`, `off`, `local` |
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ SimpleDeploy reads a small number of environment variables. The CLI prefers expl
| Name | Default | Scope | Effect |
|------|---------|-------|--------|
| `SD_PASSWORD` | (none) | client | Password used by `users create`, `apikey create`, and `registry add` when the `--password` flag is omitted. Avoids interactive stdin prompt. |
| `SIMPLEDEPLOY_ALLOW_PRIVATE_WEBHOOKS` | `0` | server | When set to `1`, the alert webhook dispatcher allows posting to private/loopback IP ranges (RFC 1918, 127.0.0.0/8). Off by default to prevent SSRF. |
| `SIMPLEDEPLOY_ALLOW_PRIVATE_WEBHOOKS` | `0` | server | When set to `1`, the alert webhook dispatcher allows posting to private/loopback IP ranges (RFC 1918, 127.0.0.0/8, CGNAT, multicast, etc.). Off by default. The dispatcher also re-validates the resolved IP at connect time to close DNS-rebinding bypass. |
| `SIMPLEDEPLOY_DISABLE_PORT_LOOPBACK` | `false` | server | When `true`, compose `ports:` mappings are NOT rewritten to `127.0.0.1:`. Default behavior pins published ports to loopback so Caddy's `simpledeploy.access.allow` and `simpledeploy.ratelimit.*` controls cannot be bypassed by direct connection. |
| `SIMPLEDEPLOY_UPSTREAM_HOST` | `localhost` | server | Overrides the host used for `localhost:<port>` upstreams. Set to `host.docker.internal` when running SimpleDeploy inside a Docker container (non-host network) so Caddy can reach app host-published ports. The Docker install docs enable this automatically. |
| `SIMPLEDEPLOY_HEALTH_PORT` | `8443` | container (healthcheck only) | Port used by the official Docker image's `HEALTHCHECK` to probe `http://localhost:$SIMPLEDEPLOY_HEALTH_PORT/api/health`. Override when your `management_port` differs from the default (e.g. `make dev-docker` sets `8500`). Not read by the simpledeploy binary itself. |

Expand Down
8 changes: 5 additions & 3 deletions docs/reference/ports.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ SimpleDeploy listens on three TCP ports by default. App container ports are boun
| `443` | Caddy | HTTPS reverse proxy for all apps with `simpledeploy.domain`. | Yes |
| `8443` | management API | Dashboard, REST API, WebSockets. | Optional |

The proxy listen address comes from `listen_addr` in `config.yaml` (default `:443`). Caddy automatically opens `:80` when `tls.mode` is `auto` so it can solve ACME challenges and redirect plaintext traffic.
The proxy listen address comes from `listen_addr` in `config.yaml` (default `:443`). Caddy automatically opens `:80` when `tls.mode` is `auto` or `local` (via `http_listen_addr`, defaulted to `:80`) so it can solve ACME challenges and 308-redirect plaintext traffic to HTTPS.

The management port comes from `management_port` (default `8443`). It serves the Svelte UI, REST API (`/api/*`), and WebSocket streams. Bind it to a private interface or front it with the same Caddy proxy under a `manage.example.com` domain if you do not want it directly reachable.
The management dashboard listens on `management_port` (default `8443`) bound to `management_addr` (default `127.0.0.1`). With the default bind, it is reachable only from the host itself; route external traffic to it through Caddy under a `manage.<domain>` route, or set `management_addr: ""` to expose every interface (legacy behavior, plain HTTP).

App containers bind whatever ports the compose file declares. SimpleDeploy never auto-publishes ports; if you do not write `ports:` in your compose, the container is reachable only on the Docker bridge network and via the Caddy reverse proxy.
App containers bind whatever ports the compose file declares — but any `ports: "8080:80"` mapping is rewritten at deploy time to `127.0.0.1:8080:80` so the published port is reachable only from the host. Caddy still proxies external traffic to the same upstream, but raw connections from outside the host cannot bypass the per-app `simpledeploy.access.allow` IP allowlist or `simpledeploy.ratelimit.*` controls. Operators who explicitly want the legacy 0.0.0.0 binding can set `SIMPLEDEPLOY_DISABLE_PORT_LOOPBACK=true`, or write the bind explicitly (`"0.0.0.0:8080:80"`) which is preserved verbatim.

If you do not write `ports:` at all, the container is reachable only on the Docker bridge network and via the Caddy reverse proxy.

## Firewall examples

Expand Down
Loading