Sign inSign up

loweel/wortwerk

By loweel

Updated about 2 hours ago

Repo for wortwerk

Image
0

2.8K

loweel/wortwerk repository overview

Wortwerk

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.

Run it

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

What you get

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.

Environment

VariableDefaultMeaning
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_USERadminOwner 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_ADDR0.0.0.0:8080Bind endpoint as host:port. Already set in the image.
WORTWERK_CONTENT_ROOT/var/lib/wortwerk/blogPosts and assets, in Hugo layout. This is the directory to back up.
WORTWERK_KEYSTORE_ROOT/var/lib/wortwerk/keystorePrivate state: the credential wallet and the configuration. Never publish it.
WORTWERK_APP_STATIC_ROOT/usr/share/wortwerk/staticThemes and assets shipped in the image. Override only to supply your own theme.
WORTWERK_NEWSLETTER_INTERVAL300Seconds between queue passes, 1 to 3600. Leave it alone unless you are testing.
DEBUGfalseVerbose 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.

Docker Swarm

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.

Volumes and backups

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.

Behind a reverse proxy

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.

Newsletter

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.

Tags

latest follows the main branch. Immutable tags are the commit hash, so loweel/wortwerk:<sha> pins a deployment to one build.

Source and licence

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.

Tag summary

Content type

Image

Digest

sha256:e72bfe605

Size

53.5 MB

Last updated

about 2 hours ago

docker pull loweel/wortwerk