How to run a Sync Server with Docker
September 15, 2026 ยท View on GitHub
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 (allowed users, email, community, analytics, encryption at rest) see 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
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:
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 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:
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
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.
A login that returns 401 after the account was created
Passwords are 8 to 64 characters with no complexity rule and no forbidden characters, and the server hashes what it receives without stripping or truncating anything, so a password that was accepted at account creation will keep working. See Account passwords for the full policy and for the log lines that name why a login was rejected.
Two container-specific causes are worth ruling out first:
- A stale volume. If the account was created against a database that has since been
recreated (
docker compose down -v, a changed volume mount), the account is simply gone and every login is a legitimate 401.Login rejected: no account for the submitted emailin the log confirms it. - Rate limiting seen as a login failure. The login limiter allows 10 requests per minute
keyed on the source address, and behind a reverse proxy that address is the proxy's, shared
by everyone. Repeated attempts return
429, not401.
Do not set bindHosts
HOW-TO-RUN-A-SERVER.md 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; 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 ahammer-owned/data/hammer_data, so the file mounts cleanly on top. - Host bind-mounted data dir: if you mount a host directory at
/datainstead, place the file at<hostdir>/hammer_data/config.tomldirectly. 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/gid1000.
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.tomlas UTF-8 without a BOM. Notepad and PowerShell'sOut-File -Encoding utf8add one, and the TOML parser rejects it with a confusingUnexpectedTokenExceptionon line 1. In PowerShell use[System.IO.File]::WriteAllText($path, $text).
Time zone
The container runs in UTC unless told otherwise, so rendered timestamps and log lines are stamped
in UTC. docker-compose.yml passes TZ through, so set it in your shell or in a .env file next
to the compose file:
TZ=Europe/Paris
Or set it directly in config.toml, which takes precedence:
timezone = "Europe/Paris"
Either way the value is an IANA zone ID; see the full list. The effective
zone is logged at startup as Server time zone: .... Stored data is unaffected, since everything
is persisted as an absolute instant. See Time zone for the
full rundown, including the one caveat when changing the zone on a server already in use.
TLS in the container
If you want Hammer itself to terminate TLS, mount the certificate directory and publish 443:
ports:
- "443:443"
volumes:
- hammer-data:/data
- /etc/letsencrypt:/certs:ro
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:
# /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:
[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.
useSsldefaults totrue. A stockpostgrescontainer serves no TLS, so leaving it unset against one fails to connect. SetuseSsl = falsefor 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):
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:
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:
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.
# 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.
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:
./gradlew :server:installDist
docker build -f docker/Dockerfile -t hammer-server .