mirror of
https://github.com/Darkrock-Studios/hammer-editor.git
synced 2026-08-06 08:09:48 +00:00
Adds trustProxyForwarding, which reads each request's client address and scheme from the X-Forwarded-* headers so the login rate limiter, the login audit trail and story reader counts see real clients rather than the proxy. Reads the last X-Forwarded-For entry rather than Ktor's default first entry, which arrives with the request and can be forged to claim a fresh rate limit bucket per request.
302 lines
12 KiB
Markdown
302 lines
12 KiB
Markdown
# How to run a Sync Server with Docker
|
|
|
|
A slim, non-root image for self-hosting the Hammer sync server. By default it
|
|
uses the in-process (embedded) PostgreSQL database, so no external services are
|
|
required: just a volume for your data.
|
|
|
|
This is the Docker-specific guide. For everything that is not Docker-specific
|
|
(whitelisting, email, community, analytics, encryption at rest) see
|
|
[HOW-TO-RUN-A-SERVER.md](HOW-TO-RUN-A-SERVER.md). Where the two disagree about
|
|
networking, **this document wins for containers**.
|
|
|
|
> Running a server reachable on the internet is an inherently technical task. If
|
|
> you have never done it, this is probably not for you.
|
|
|
|
## Quick start
|
|
|
|
```bash
|
|
cd docker
|
|
docker compose up -d
|
|
```
|
|
|
|
That pulls `ghcr.io/darkrock-studios/hammer-editor/server:latest`, keeps all
|
|
durable state in the `hammer-data` volume, and serves plain HTTP on
|
|
`127.0.0.1:8080`.
|
|
|
|
Or without compose:
|
|
|
|
```bash
|
|
docker run -d --name hammer-server \
|
|
-p 127.0.0.1:8080:8080 \
|
|
-v hammer-data:/data \
|
|
ghcr.io/darkrock-studios/hammer-editor/server:latest
|
|
```
|
|
|
|
That gets the server running, but **a client cannot talk to it yet**. Hammer
|
|
clients only speak HTTPS, and the container serves plain HTTP, so put TLS in
|
|
front of it first: see [Networking](#networking) below. A client pointed at the
|
|
cleartext port fails its TLS handshake and reports that it could not make a
|
|
secure connection.
|
|
|
|
Once TLS is in place, **download a client and create an account**. The first
|
|
account created becomes the admin account.
|
|
|
|
## Networking
|
|
|
|
The container serves **plain HTTP** on port 8080. Hammer clients only speak
|
|
HTTPS, so that port is never what clients talk to directly. Two supported shapes:
|
|
|
|
- **Reverse proxy (recommended):** a proxy (Nginx, Caddy, Traefik) terminates TLS
|
|
and forwards plain HTTP to the container.
|
|
- **Hammer terminates TLS:** mount a certificate, set `sslCert`, publish 443.
|
|
|
|
Both quick starts above publish to `127.0.0.1` deliberately. Docker installs its
|
|
own iptables rules that **bypass host firewalls like ufw and firewalld**, so
|
|
publishing to `0.0.0.0` puts the cleartext port on the public internet even when
|
|
the host firewall says otherwise, exposing passwords, tokens, and sync traffic.
|
|
|
|
To change the host-side binding or port:
|
|
|
|
```bash
|
|
HAMMER_HTTP_BIND=0.0.0.0 HAMMER_HTTP_PORT=8090 docker compose up -d
|
|
```
|
|
|
|
### Reverse proxy: set `trustProxyForwarding`
|
|
|
|
With a proxy in front, every request reaches the container from the proxy's address, so the
|
|
login rate limiter treats the whole server as one client and the login audit trail records one
|
|
address for everyone. Setting `trustProxyForwarding = true` in `config.toml` makes Hammer read
|
|
the real client from `X-Forwarded-For`. See
|
|
[Behind a proxy](HOW-TO-RUN-A-SERVER.md#behind-a-proxy-every-request-looks-like-it-came-from-the-proxy)
|
|
for the full picture, the warning that goes with it, and what to do when a CDN sits in front of
|
|
your proxy.
|
|
|
|
That warning matters more in a container than anywhere else: the flag is only safe while the
|
|
proxy is the *only* route in. Publishing the container port to `0.0.0.0` (above) reopens a
|
|
direct path, and with `trustProxyForwarding` on, anything reaching it can forge a client address per
|
|
request and walk through the rate limiter. Keep the publish on `127.0.0.1`, or on a Docker
|
|
network only the proxy shares.
|
|
|
|
### A client that will not connect
|
|
|
|
If a client reports that it could not make a secure connection, it reached the
|
|
cleartext port and got no TLS. Add a reverse proxy or a certificate as above.
|
|
|
|
The server log is misleading here. A client's TLS handshake is rejected by the
|
|
HTTP parser before any route runs, so nothing about it appears in the
|
|
application log. What you will see instead are `UnsupportedProtocolVersionException`
|
|
entries from your own browser or `curl` hitting `/api/...`, since only Hammer
|
|
clients send the `X-Hammer-Protocol-Version` header. Those are unrelated to the
|
|
client's failure.
|
|
|
|
### Do not set `bindHosts`
|
|
|
|
[HOW-TO-RUN-A-SERVER.md](HOW-TO-RUN-A-SERVER.md#reverse-proxy-using-nginx) tells
|
|
reverse-proxy operators to set `bindHosts = ["127.0.0.1", "::1"]`. **That advice
|
|
does not apply to containers.** Inside a container it binds the *container's* own
|
|
loopback, so the published port forwards to an address nothing is listening on:
|
|
every request fails, while the container still logs a clean startup and reports
|
|
healthy. Leave `bindHosts` unset and restrict exposure on the host side instead.
|
|
|
|
## Configuration
|
|
|
|
The server auto-loads `config.toml` from its data directory
|
|
(`/data/hammer_data/config.toml`), with no `--config` flag needed. Start from
|
|
[config.example.toml](../docker/config.example.toml); everything in it is
|
|
optional.
|
|
|
|
Two ways to provide it:
|
|
|
|
- **Named volume (default):** uncomment the config bind mount in
|
|
`docker-compose.yml`. This works because the image ships a `hammer`-owned
|
|
`/data/hammer_data`, so the file mounts cleanly on top.
|
|
- **Host bind-mounted data dir:** if you mount a host directory at `/data`
|
|
instead, place the file at `<hostdir>/hammer_data/config.toml` directly. Don't
|
|
bind-mount the single file in that case, because Docker would create the parent
|
|
as root. Make sure the directory is owned by uid/gid `1000`.
|
|
|
|
A bad config aborts startup rather than silently falling back to defaults, so
|
|
check `docker logs` if the container exits immediately.
|
|
|
|
Paths inside `config.toml` are resolved differently depending on the setting:
|
|
`termsOfService` and `privacyPolicy` resolve relative to the config file, but
|
|
**TLS certificate paths do not**, so give those absolute container paths.
|
|
|
|
> **Windows users:** save `config.toml` as UTF-8 **without** a BOM. Notepad and
|
|
> PowerShell's `Out-File -Encoding utf8` add one, and the TOML parser rejects it
|
|
> with a confusing `UnexpectedTokenException` on line 1. In PowerShell use
|
|
> `[System.IO.File]::WriteAllText($path, $text)`.
|
|
|
|
## TLS in the container
|
|
|
|
If you want Hammer itself to terminate TLS, mount the certificate directory and
|
|
publish 443:
|
|
|
|
```yaml
|
|
ports:
|
|
- "443:443"
|
|
volumes:
|
|
- hammer-data:/data
|
|
- /etc/letsencrypt:/certs:ro
|
|
```
|
|
|
|
```toml
|
|
sslPort = 443
|
|
|
|
[sslCert]
|
|
certChainPath = "/certs/live/hammer.example.com/fullchain.pem"
|
|
privateKeyPath = "/certs/live/hammer.example.com/privkey.pem"
|
|
```
|
|
|
|
**Renewals need a restart.** The server reads its certificate only at startup, so
|
|
a renewed certificate is not picked up until the container restarts. Mount the
|
|
live certificate directory (as above) rather than copying PEMs into the volume,
|
|
and restart the container after each renewal, e.g. as a certbot deploy hook:
|
|
|
|
```ini
|
|
# /etc/letsencrypt/renewal/hammer.example.com.conf, under [renewalparams]
|
|
deploy_hook = docker restart hammer-server
|
|
```
|
|
|
|
Without this, sync silently stops for every user about 90 days in, when clients
|
|
begin rejecting the expired certificate.
|
|
|
|
Self-signed certificates are not supported: mobile clients trust only the system
|
|
CA store. Use a real certificate, or a reverse proxy holding one.
|
|
|
|
## Using an external PostgreSQL
|
|
|
|
The default is an in-process PostgreSQL inside the container: nothing else to
|
|
run, and the right choice for most self-hosters. To point at an externally
|
|
managed database, switch `config.toml` to remote storage:
|
|
|
|
```toml
|
|
[storage]
|
|
type = "remote"
|
|
|
|
[storage.remote]
|
|
host = "postgres"
|
|
port = 5432
|
|
database = "hammer"
|
|
user = "hammer"
|
|
password = "change-me"
|
|
useSsl = false
|
|
```
|
|
|
|
The schema is created and migrated automatically on first connect, so an empty
|
|
database is all that's needed. `docker-compose.yml` contains a commented-out
|
|
`postgres` service (plus the matching `depends_on`) to uncomment for this; `host`
|
|
must match that service's name.
|
|
|
|
> **`useSsl` defaults to `true`.** A stock `postgres` container serves no TLS, so
|
|
> leaving it unset against one fails to connect. Set `useSsl = false` for a local
|
|
> sidecar; keep it on for a managed database that terminates TLS.
|
|
|
|
In remote mode the `/data` volume still holds the caches and keyring, but no
|
|
`pgdata`. Back up your PostgreSQL server instead.
|
|
|
|
## Data & backups
|
|
|
|
Everything durable lives under the `/data` volume:
|
|
|
|
```
|
|
/data/hammer_data/
|
|
pgdata/ embedded PostgreSQL data
|
|
cache/ regenerable render/OpenGraph caches
|
|
config.toml your config (if you put it here)
|
|
server.keyring.json encryption keyring (only if encryption is enabled)
|
|
```
|
|
|
|
Back up the volume to back up the server. If you use a host bind mount instead of
|
|
a named volume, make sure the directory is writable by uid/gid `1000` (the
|
|
image's `hammer` user):
|
|
|
|
```bash
|
|
sudo chown -R 1000:1000 /path/to/your/data
|
|
```
|
|
|
|
## Admin CLI subcommands
|
|
|
|
The image entrypoint is the server launcher, so subcommands work by appending
|
|
them to `docker run` with the data volume mounted:
|
|
|
|
```bash
|
|
docker run --rm -v hammer-data:/data \
|
|
ghcr.io/darkrock-studios/hammer-editor/server:latest \
|
|
generate-keyring --out /data/hammer_data/server.keyring.json
|
|
```
|
|
|
|
Two rules matter in a container:
|
|
|
|
**Anything meant to persist needs `--out` pointing into the volume.** These
|
|
commands print to stdout by default, and with `--rm` that output is all you get.
|
|
|
|
**Commands that read the database need the server stopped**, but only with the
|
|
default embedded storage. The embedded PostgreSQL holds an exclusive lock on
|
|
`pgdata`, so a second container cannot open it while the server is running:
|
|
|
|
```
|
|
Could not read the database to verify which content keys are in use:
|
|
could not lock /data/hammer_data/pgdata/epg-lock
|
|
```
|
|
|
|
This fails safely (nothing is written or corrupted), but the command does not
|
|
run. Stop the server first, then start it again afterwards:
|
|
|
|
```bash
|
|
docker stop hammer-server
|
|
docker run --rm -v hammer-data:/data \
|
|
ghcr.io/darkrock-studios/hammer-editor/server:latest \
|
|
prune-key --role content --config /data/hammer_data/config.toml --dry-run
|
|
docker start hammer-server
|
|
```
|
|
|
|
| Command | Needs the database | Server must be stopped |
|
|
|---------|--------------------|------------------------|
|
|
| `generate-keyring` | no | no |
|
|
| `rotate-key` | no | no |
|
|
| `inspect-keyring` | no | no |
|
|
| `migrate-secret` | no | no |
|
|
| `prune-key --role tokenHmac` | no | no |
|
|
| `prune-key --role content` | yes | yes (embedded storage) |
|
|
| `--converge-dry-run` | yes | yes (embedded storage) |
|
|
|
|
The lock is specific to the embedded database. With `[storage] type = "remote"`
|
|
the database is a separate service, so these commands can run against a live
|
|
server.
|
|
|
|
### Rotating an encryption key
|
|
|
|
Key rotation itself never touches the database. Rotate offline, write the result
|
|
into the volume, then restart: the server reads its keyring only at startup, so
|
|
a rotated keyring has no effect until the container restarts.
|
|
|
|
```bash
|
|
# Review the rotated keyring first (prints to stdout, writes nothing)
|
|
docker run --rm -v hammer-data:/data \
|
|
ghcr.io/darkrock-studios/hammer-editor/server:latest rotate-key --role content
|
|
|
|
# Then write it and restart to pick it up
|
|
docker run --rm -v hammer-data:/data \
|
|
ghcr.io/darkrock-studios/hammer-editor/server:latest \
|
|
rotate-key --role content --out /data/hammer_data/server.keyring.json
|
|
docker restart hammer-server
|
|
```
|
|
|
|
Back up the existing keyring before overwriting it. Losing key material means
|
|
losing the content encrypted under it. See
|
|
[SERVER-SECRET-STORAGE.md](SERVER-SECRET-STORAGE.md).
|
|
|
|
Pruning old generations comes *after* the server has converged existing rows onto
|
|
the new key, and needs the server stopped as described above.
|
|
|
|
## Building the image yourself
|
|
|
|
The image packages the pre-built server distribution rather than compiling from
|
|
source (the server shares the `:base` module with the Android client, so a source
|
|
build needs the Android SDK). From the repo root:
|
|
|
|
```bash
|
|
./gradlew :server:installDist
|
|
docker build -f docker/Dockerfile -t hammer-server .
|
|
```
|