A blog engine for one person, written in Ada. It keeps your posts as Markdown files in a Hugo layout, serves them itself, and mails them to your subscribers. There is no database and no build step: what you write is a file on a disk you control.
Every decision that can be decided on paper is proved rather than tested. Path handling, slug rules, the login throttle, the archive import limits and the send quota are SPARK code, and the build fails if a proof fails. At the time of writing that is 993 checks with none left unproved.
The image serves plain HTTP on port 8080 and expects a TLS terminator in front of it. It runs as a non-root user and keeps everything it owns under one volume.
docker run -d --name blog \
-p 8080:8080 \
-v wortwerk-data:/var/lib/wortwerk \
-e WORTWERK_KEYSTORE_SECRET='a-long-random-string-of-at-least-16-chars' \
-e WORTWERK_INITIAL_USER=admin \
-e WORTWERK_INITIAL_PASSWORD='at-least-twelve-characters' \
loweel/wortwerk:latest
Then open http://localhost:8080/ for the blog and /login to sign in.
/health answers without authentication and is what a healthcheck
should poll.
Generate the secret rather than inventing one:
head -c 32 /dev/urandom | base64
The blog. A reader page listing your posts, a page for each post,
and an Atom feed at /feed.atom.
An editor at New post in the admin, with a toolbar, image upload
and a live preview. Images are stored once, named after their own
content hash, and served from /static/images/.
A newsletter. Readers subscribe from the blog and confirm by email. Publishing a post and pressing the envelope queues it; a task inside the server empties the queue. Delivery is recorded per recipient, so an interrupted send resumes without anyone getting the article twice.
Relay quotas that are honoured. Set how many messages a day and a month your relay allows. When the allowance runs out the send stops, the rest stays queued, and it continues by itself when the window opens again.
Statistics in the admin, showing the newsletter figures and what is left of each allowance.
Import and export of the whole content tree as a zip.
| Variable | Default | Meaning |
|---|---|---|
WORTWERK_KEYSTORE_SECRET | (none) | Opens the credential wallet. Required, 16 to 4096 characters. Keep it stable: change it and the existing wallet stops opening. |
WORTWERK_INITIAL_USER | admin | Owner username, 1 to 256 characters. Checked at every boot, so it has to stay set. |
WORTWERK_INITIAL_PASSWORD | (none) | Owner password, 12 to 4096 characters. Despite the name it is not only initial: it is verified against the wallet at every boot. See the warning below. |
WORTWERK_LISTEN_ADDR | 0.0.0.0:8080 | Bind endpoint as host:port. Already set in the image. |
WORTWERK_CONTENT_ROOT | /var/lib/wortwerk/blog | Posts and assets, in Hugo layout. This is the directory to back up. |
WORTWERK_KEYSTORE_ROOT | /var/lib/wortwerk/keystore | Private state: the credential wallet and the configuration. Never publish it. |
WORTWERK_APP_STATIC_ROOT | /usr/share/wortwerk/static | Themes and assets shipped in the image. Override only to supply your own theme. |
WORTWERK_NEWSLETTER_INTERVAL | 300 | Seconds between queue passes, 1 to 3600. Leave it alone unless you are testing. |
DEBUG | false | Verbose logging. Off in production. |
Three rules worth repeating, because they are the ones that hurt later.
The keystore secret must not change after the first boot, or the wallet stops opening and the container will not start.
The owner username and password are not read only once. At every boot, if the wallet already exists, the values in the environment are checked against it and a mismatch stops the server. Both have to stay set.
Do not change the owner password from inside the application yet. It
updates the wallet and not the environment, so the next restart finds a
password that no longer matches and refuses to start. Change it by
changing WORTWERK_INITIAL_PASSWORD and restarting.
version: "3.8"
services:
blog:
image: loweel/wortwerk:latest
environment:
WORTWERK_INITIAL_USER: admin
WORTWERK_LISTEN_ADDR: 0.0.0.0:8080
volumes:
- wortwerk-data:/var/lib/wortwerk
networks:
- web
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
deploy:
replicas: 1
restart_policy:
condition: any
update_config:
order: stop-first
labels:
- "traefik.enable=true"
- "traefik.http.routers.blog.rule=Host(`blog.example.net`)"
- "traefik.http.routers.blog.entrypoints=websecure"
- "traefik.http.routers.blog.tls.certresolver=le"
- "traefik.http.services.blog.loadbalancer.server.port=8080"
volumes:
wortwerk-data:
networks:
web:
external: true
docker stack deploy -c docker-compose.yml blog
The secrets are deliberately not in that file. Add them to the service, where they do not end up in version control:
docker service update \
--env-add WORTWERK_KEYSTORE_SECRET="$(head -c 32 /dev/urandom | base64)" \
--env-add WORTWERK_INITIAL_USER=admin \
--env-add WORTWERK_INITIAL_PASSWORD='choose-twelve-chars-or-more' \
blog_blog
Write the secret down first. Neither value can be removed later with
--env-rm: the secret opens the wallet at every start and the
credentials are checked against it at every start.
Three things in the stack file are deliberate. One replica, because
Wortwerk writes posts as files under one volume and does not coordinate
writers, so a second replica on another node would corrupt the content.
stop-first, for the same reason: two containers must never hold the
volume at once. The healthcheck speaks HTTP through bash, because
the runtime image carries the curl library for sending mail but no
curl or wget command.
One volume holds everything, /var/lib/wortwerk, with blog/ and
keystore/ inside it. Backing up the whole volume is enough. If you
back the two up separately, remember that blog/ is publishable and
keystore/ is not: the wallet and the SMTP password live there.
Wortwerk speaks plain HTTP and does not terminate TLS. Give the proxy
the usual job: certificates, HSTS, and forwarding X-Forwarded-Proto.
Point it at port 8080 and nothing else. There is no separate admin port;
the owner area is on the same port behind the session cookie, which is
issued Secure and SameSite=Strict.
Fill the SMTP section in the admin with your relay's host, port, credentials and the daily and monthly allowances it grants you. Set the allowances to zero if there are none.
One message goes out per recipient, because each carries its own unsubscribe link, which is not optional. That is also why a large list takes a while: the queue is drained in the background and you can close the browser.
latest follows the main branch. Immutable tags are the commit hash, so
loweel/wortwerk:<sha> pins a deployment to one build.
https://git.keinpfusch.net/loweel/Wortwerk, EUPL-1.2.
The editor is Toast UI Editor and the statistics dashboard is GoAccess's report page, both MIT, both vendored with their notices.
Content type
Image
Digest
sha256:e72bfe605…
Size
53.5 MB
Last updated
about 2 hours ago
docker pull loweel/wortwerk