Most writing tools make you pick a side: a polished app that owns the sync story, or a plain-file workflow that leaves the app experience behind.
Hammer is interesting because it tries to sit in the middle. It is a native story editor for people writing books, scenes, notes, timelines, drafts, and encyclopedia entries.
The app works offline first, and the sync server is optional.
Offline-first here does not mean “PWA with a service worker.” Hammer is not a browser app. It ships native clients for mobile and desktop, stores projects on the device filesystem, and only talks to a sync server when you choose to connect one.
Hammer Editor source code on GitHub Hammer Docker server guide Hammer releases License: MIT
What is Hammer?
Hammer describes itself as “a simple tool for building stories.” In practice, that means a writing environment aimed at long-form fiction instead of generic documents.
The repository ships clients for Android, iOS, Windows, Linux, and macOS. The app is built with Kotlin Multiplatform and JetBrains Compose, while the optional sync server is a Kotlin/Ktor service packaged as a Docker image.
The local-first angle matters. Hammer projects are meant to stay usable without a server. Sync is for multi-device continuity, not the core place where your writing lives.
In the codebase this shows up as a projectsDirectory setting, ProjectDef values that point at local paths, repository code that creates project folders directly through okio.FileSystem, and sync metadata that can be cleared or rebuilt without deleting the local project content.
Why Self-Host the Sync Server?
You do not need a server just to write with Hammer.
Self-hosting becomes useful when you want:
- the same writing projects across phone, tablet, laptop, and desktop
- control over where account data and synced projects live
- a private server instead of the hosted Hammer sync service
- a straightforward Docker service that can sit behind your existing reverse proxy
The important deployment detail is that Hammer clients require HTTPS. The default Docker container serves plain HTTP on port 8080, so you should keep that port bound to localhost and terminate TLS at Caddy, Traefik, Nginx, or another reverse proxy.
Repository Snapshot
I cloned the project into:
tmp/foss-post/hammer-editor
The checkout I inspected reported:
| Item | Value |
|---|---|
| Project | Darkrock-Studios/hammer-editor |
| License | MIT |
App version in gradle/libs.versions.toml |
3.8.2 |
| Kotlin | 2.4.10 |
| JVM target | 21 |
| UI stack | Kotlin Multiplatform, JetBrains Compose, Decompose |
| Server stack | Ktor 3.5.2, Jetty, Koin, SQLDelight, PostgreSQL |
| Default Docker image | ghcr.io/darkrock-studios/hammer-editor/server:latest |
The repository is split into the expected multiplatform layers:
| Area | What it contains |
|---|---|
android/ |
Android app target |
desktop/ |
desktop packaging and runtime |
composeUi/ |
Compose UI shared by client surfaces |
common/ |
shared app data model, repositories, importers, sync client logic |
base/ |
shared HTTP DTOs, hashing, filesystem helpers, protocol contracts |
server/ |
Ktor sync server, auth, admin pages, monitoring, PostgreSQL persistence |
docker/ |
official Dockerfile, Compose file, and server config example |
docs/ |
architecture, sync protocol, server setup, Docker, encryption, publishing |
Architecture Notes
Hammer uses a Clean-style architecture on the client side. The documented dependency direction is:
Data Source -> Foundation -> Repository -> Service -> Use Case -> ViewModel/Component -> UI
That is useful for a writing app because the local file model, sync journal, ID allocation, search, statistics, editor buffers, and import/export paths can become tangled quickly.
The codebase gives those concerns distinct places:
ProjectsRepositoryhandles project folders and project definitions.SceneRepository,SceneContentRepository, andSceneMetadataRepositorycover scene trees, scene content, and metadata.SyncJournalrecords dirty entities and local changes waiting for server reconciliation.ClientAccountSynchronizer,ClientProjectSynchronizer, and entity synchronizers coordinate the client/server sync flow.- The server stores account/project data in PostgreSQL using SQLDelight-generated database access.
The sync protocol has two levels:
| Sync Level | Purpose |
|---|---|
| Account sync | Create, rename, delete, and assign IDs to projects, plus account-level story ideas |
| Project sync | Reconcile the entities inside each project |
Hammer also keeps deletion tombstones and content hashes so clients can detect unchanged projects, handle offline edits, and avoid pushing stale state blindly.
Self-Hosting Hammer with Docker
The official Docker path is the right starting point for most self-hosters. The server uses embedded PostgreSQL by default, so a single container plus one volume is enough for a first deployment.
This Docker service is the sync/account server, not the Hammer editor UI. You still install the Hammer app on your phone, tablet, laptop, or desktop, then point that app at your HTTPS sync endpoint.
I added a reusable Home-Lab compose snippet here:
Hammer Docker Compose in Home-Labservices:
hammer:
image: ghcr.io/darkrock-studios/hammer-editor/server:latest
container_name: hammer-server
restart: unless-stopped
init: true
ports:
- "${HAMMER_HTTP_BIND:-127.0.0.1}:${HAMMER_HTTP_PORT:-8080}:8080"
volumes:
- hammer-data:/data
volumes:
hammer-data:
Start it:
docker compose up -d
docker compose logs -f
By default this binds:
127.0.0.1:8080 -> container:8080
That is deliberate. The service on port 8080 is plain HTTP. Put a reverse proxy in front of it and expose the HTTPS URL to Hammer clients.
Reverse Proxy Shape
A minimal Caddy-style shape is:
hammer.example.com {
reverse_proxy 127.0.0.1:8080
}
In production, set Hammer’s publicUrl and enable proxy trust only when the proxy is the only route into the container:
publicUrl = "https://hammer.example.com"
trustProxyForwarding = true
Do not set bindHosts = ["127.0.0.1"] inside the Docker config. In a container, that binds the container loopback, not the host loopback, and the published port will not reach the server.
Local Field Test
I tested the official Docker path on this workstation:
OS/runtime: Docker 29.7.2, Docker Compose v5.4.0
Disk before test: 15GB free on /
Memory before test: 14GiB total, about 1.5GiB available, no swap
Command: docker compose -f tmp/foss-post/hammer-editor/docker/docker-compose.yml up -d
Image: ghcr.io/darkrock-studios/hammer-editor/server:latest
Image digest inspected locally: sha256:765f464283d72d7255a9ba9b79ad9ce3d0b3d42ac1701ea3115b6118f45be6e9
Image size inspected locally: 303,940,107 bytes
The container became healthy, initialized embedded PostgreSQL, and answered:
HTTP/1.1 302 Found
Location: /setup
X-Hammer-Protocol-Version: 3
X-Server-Version: 3.8.2
Server: Ktor/3.5.2
After the smoke test I stopped the container:
docker compose -f tmp/foss-post/hammer-editor/docker/docker-compose.yml stop
I did not delete the Docker image or the hammer_hammer-data Docker volume. They remain available locally if you want to continue testing.
Configuration Notes
The container auto-loads config from:
/data/hammer_data/config.toml
The default storage mode is embedded PostgreSQL:
/data/hammer_data/pgdata
You can switch to a separate PostgreSQL service with:
[storage]
type = "remote"
[storage.remote]
host = "postgres"
port = 5432
database = "hammer"
user = "hammer"
password = "change-me"
useSsl = false
For a single-user self-hosted server, the embedded database is simpler. Use remote PostgreSQL when you already have database operations, backups, and monitoring you trust.
Encryption at Rest
Hammer can encrypt synced user content in the database with AES-GCM, but the docs are careful about the tradeoff.
Fresh servers default to plaintext storage. Encryption requires an offline keyring, maintenance commands, and key backups.
For a personal single-user instance, I would start without server-side database encryption and focus first on:
- HTTPS transport
- host backups
- private access to the admin/server URL
- volume snapshots
- keeping the key material out of the database backup if encryption is enabled later
This is not end-to-end encryption. It is database-at-rest protection for the sync server.
Where Hammer Fits
Hammer is not an Obsidian clone, a generic Markdown wiki, or a browser-only writing app.
It is closer to:
- Scrivener-style story organization, but open source and multiplatform
- local-first Markdown-ish project storage, but with a richer story model
- a private sync service for native apps, not a public CMS
That makes it especially interesting if you want a real writing app with a self-hostable sync backend, rather than a self-hosted web editor pretending to be a native writing studio.
FAQ
Can I use Hammer without self-hosting anything?
Is Hammer a PWA?
Can Hammer clients connect to plain HTTP?
Does the Docker setup require a separate PostgreSQL container?
Who becomes the admin user?
Should I expose port 8080 publicly?
Is server-side encryption worth enabling?
Conclusion
Hammer is one of the more practical open-source writing tools I have seen recently because it keeps the client experience native and local-first, while still giving self-hosters a real sync server.
The Docker server path is clean: one image, one volume, embedded PostgreSQL by default, and a clear reverse-proxy requirement.
The only sharp edge is also the important one: clients need HTTPS, so do not mistake the local 8080 listener for a production endpoint.
Comments