Skip to content

Volumes instead of bind mounts (+ DKIM-key generation) - #58

Open
chrisblech wants to merge 3 commits into
springcomp:mainfrom
chrisblech:volumes-instead-of-bind-mounts
Open

Volumes instead of bind mounts (+ DKIM-key generation)#58
chrisblech wants to merge 3 commits into
springcomp:mainfrom
chrisblech:volumes-instead-of-bind-mounts

Conversation

@chrisblech

Copy link
Copy Markdown
Contributor

Volumes instead of bind mounts (+ DKIM-key generation)

Motivation

The current setup relies on bind mounts (./db, ./pgp, ./upload,
./dkim.key, ./unbound/conf.d/) for anything that needs to persist or be
pre-provisioned. That works fine when you deploy from a shell on the host,
but it quietly assumes host filesystem access: someone needs to mkdir
the right directories and drop a dkim.key file in the right place before
docker compose up will actually work correctly.

That assumption breaks down for Portainer's Web editor / Git deployment
without SSH access to the host - a fairly common setup (managed Portainer,
restricted hosts, teammates who only get Portainer access). Named Docker
volumes are created and populated entirely by the Docker daemon itself, so
switching to them means the whole stack - including first-time DKIM key
generation - can be deployed and managed purely through the Portainer UI,
no host shell required.

This PR switches every bind mount to a named volume, adds a migration path
for existing deployments, and folds in a small related improvement
(DKIM key generation on first start) that enables to bring up the whole setup
without fiddling anything on the host.

What changed

1. Named volumes replace bind mounts

  x-sl-defaults: &sl-defaults
    image: simplelogin/$SL_IMAGE:$SL_VERSION
    env_file: .env
    volumes:
-     - ./pgp:/sl/pgp
-     - ./upload:/code/static/upload
-     - ./dkim.key:/dkim.key
+     - sldata:/sl
+     - upload:/code/static/upload

sldata is mounted once at /sl, with pgp/, dkim/, tmp/ and
unsent/ as subdirectories, rather than one volume per bind mount.
postgres gets a db volume the same way.

DKIM_PRIVATE_KEY_PATH, GNUPGHOME, TEMP_DIR and SAVE_UNSENT_DIR are
now fixed in the sl-defaults anchor's environment: instead of being
.env-configurable - these are container-internal implementation
details of the volume layout, not something a deployer needs (or should
want) to change. Setting them as literal environment: values means they
take precedence over anything env_file: provides, so an existing .env
that still has the old DKIM_PRIVATE_KEY_PATH=/dkim.key is automatically
overridden rather than breaking anything.

2. Migration path for existing deployments

New migrate-volumes-compose.yaml, included by default from
docker-compose.yaml, adds a one-shot volume-migrate service:

services:
  volume-migrate:
    image: alpine:3.22
    volumes:
      - .:/legacy:ro
      - db:/new/db
      - sldata:/new/sldata
      - upload:/new/upload
    entrypoint: [...]  # copies ./db, ./pgp, ./upload, ./dkim.key into the
                        # new volumes, but only if the volume is still empty

  postgres:
    depends_on:
      volume-migrate:
        condition: service_completed_successfully

The whole project directory is bind-mounted read-only as a single mount
(.:/legacy:ro) rather than mounting each legacy path individually -
mounting a nonexistent single file/directory path directly would make
Docker silently create an empty directory there instead of leaving it
absent, which matters most for ./dkim.key (commonly absent on a fresh
install) but is avoided for all four legacy paths this way.

It's safe to leave this file included even on a fresh install with none
of the legacy paths present - it just creates the sldata directory
structure and otherwise does nothing. Comment out its include: line in
docker-compose.yaml once you've confirmed an existing deployment
migrated successfully.

3. DKIM key generation, key echoed on app startup

A entrypoint override in the init container ensures presence of all
paths (regardless of whether migrate-volumes-compose.yaml
was used), and generates a DKIM-key if there is none present.

init:
  <<: *sl-defaults
  entrypoint:
    - /bin/sh
    - -c
    - |
      set -eu
      umask 077
      mkdir -p /sl/pgp /sl/dkim /sl/tmp /sl/unsent
      if [ ! -s "$$DKIM_PRIVATE_KEY_PATH" ]; then
        echo "[dkim-init] No key at $$DKIM_PRIVATE_KEY_PATH — generating 1024-bit RSA key..."
        openssl genrsa -out "$$DKIM_PRIVATE_KEY_PATH" 1024
      fi
      chmod 600 "$$DKIM_PRIVATE_KEY_PATH" 2>/dev/null || true
      exec python init_app.py

app now also prints the DKIM public key on every start, as a
convenience for setting up the DNS TXT record:

[dkim-init] DKIM public key (p=) — add this to your DNS TXT record:
v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB...

4. DNS resolver config written at container start instead of bind-mounted

As we cannot rely on bind-mounts from the host any more, there is another
approach to inject unbound configuration into dns container: an entrypoint
override writes our config file on startup.

    dns:
      image: crazymax/unbound:latest
-     volumes:
-       - ./unbound/conf.d/:/config/:ro
      entrypoint:
        - /bin/sh
        - -ec
        - |
+         cat > /config/00-listen-port.conf <<'EOF'
+         server:
+           interface: 0.0.0.0@53
+           log-queries: yes
+           verbosity: 2
+           module-config: "validator iterator"
+           identity: "DNS"
+           root-hints: "/usr/share/dns-root-hints/named.root"
+         EOF
          unbound-anchor -a /var/run/unbound/root.key || true
          exec su -s /bin/sh unbound -c "sh /entrypoint.sh"

Testing

Everything below was checked with real containers, not just docker compose config:

  • init's entrypoint against a real sl-app image and a fresh volume:
    creates /sl/{pgp,dkim,tmp,unsent} with correct permissions
    (700/600), generates a working RSA key; a second run correctly skips
    regeneration
  • app's entrypoint against the generated key produces a valid DKIM
    public key in the expected DNS TXT format
  • volume-migrate against fake legacy ./db, ./pgp, ./upload,
    ./dkim.key content: copies all four correctly on first run; a second
    run skips all four (idempotent, never overwrites)
  • the resolver: still resolves and DNSSEC-validates (a signed test zone
    returns the ad flag); unbound-checkconf -o confirms
    module-config/identity/root-hints now match the old config
    exactly; the image's built-in healthcheck (port 5053) is unaffected by
    the new port-53 listener, since both are registered additively via the
    image's own include: /config/*.conf
  • docker compose config renders cleanly end-to-end with no
    variable-interpolation warnings (had to escape the new shell variables
    as $$, same convention used elsewhere in this file - Compose
    interpolates the whole file's string values, including inside
    multi-line shell script blocks, before service definitions are parsed)

No changes to the SimpleLogin app image itself - this is purely compose
orchestration.

Replaces ./db, ./pgp, ./upload, ./dkim.key bind mounts with named volumes
(db, sldata, upload). sldata is mounted once at /sl with subdirectories
for pgp/dkim/tmp/unsent, rather than one volume per bind mount.

DKIM_PRIVATE_KEY_PATH, GNUPGHOME, TEMP_DIR and SAVE_UNSENT_DIR are now
fixed in the sl-defaults anchor's `environment:` instead of .env.example -
there's no reason for these container-internal paths to be user
configurable, and setting them as literal `environment:` values (which
take precedence over env_file-provided ones) means any stale value in an
existing .env is automatically overridden, with no breaking change
required for upgraders.

Also adds DKIM key auto-generation, moved into `init` rather than
`migration` (checked: nothing in init_app.py, any migration script, or
migrations/env.py touches DKIM/GNUPGHOME/filesystem paths at all, so
there's no dependency requiring it to run as part of the DB migration
step - `init` is the more fitting home, since `migration`'s job should
stay limited to schema changes). Runs on every start but only ever acts
once: the mkdir -p and the missing-key check are both no-ops afterwards.
This also means it's the mechanism that provisions sldata correctly even
when migrate-volumes-compose.yaml has been commented out or was never
used.

New file migrate-volumes-compose.yaml (included by default, meant to be
commented out of docker-compose.yaml once migration is confirmed) adds a
`volume-migrate` one-shot service and gives `postgres` an extra
depends_on: volume-migrate: service_completed_successfully (merged
in via Compose's cross-file service merge, verified with `docker compose
config` - postgres's own definition elsewhere doesn't need touching). The
whole project directory is bind-mounted read-only as a single mount
(`.:/legacy:ro`) rather than mounting each legacy path individually -
mounting a single nonexistent file/dir source directly would make Docker
silently create an empty directory there, which matters most for
./dkim.key (frequently absent on a fresh setup) but avoids the same
footgun for ./db/./pgp/./upload too. Copies are skip-if-target-non-empty,
so safe to run on every start; never overwrites.

Verified locally (no changes needed to the app image - purely compose
orchestration):
- `docker compose config` renders cleanly, no stray variable-interpolation
  warnings (had to escape shell variables in the new entrypoints as $$,
  same as elsewhere in this file - a bare $DKIM_PRIVATE_KEY_PATH gets
  swallowed by Compose's own interpolation and rendered empty, since
  Compose interpolates the whole file's string values before service
  definitions are even parsed, with no way to mark a block as "literal
  shell, don't touch")
- init's entrypoint against a real sl-app image + fresh volume: creates
  /sl/{pgp,dkim,tmp,unsent} with correct permissions (700/600), generates
  a working RSA key; second run correctly skips regeneration
- app's entrypoint against the generated key: produces a valid DKIM
  public key in the expected DNS TXT format
- volume-migrate against fake legacy content (db/pgp/upload dirs +
  dkim.key): copies all four correctly; second run skips all four
  (already-migrated, idempotent)
Replaces ./unbound/conf.d/:/config/:ro with an inline heredoc in the dns
service's entrypoint, so it has no host-file dependency at all, matching
the direction of the previous commit (avoid bind mounts). Recipe:
springcomp#46 (comment)

Removes the now-unused unbound/conf.d/ directory.

Verified in isolation: the resolver container actually resolves and
DNSSEC-validates (query for example.com returns a real answer over the
new port-53 listener); the injected /config/00-listen-port.conf is picked
up via the image's own `include: /config/*.conf`, additively alongside
its default listener on 5053 - so the image's built-in healthcheck
(which probes 5053) is unaffected and `dns: condition: service_healthy`
on postfix still gates correctly.
The previous bind-mounted unbound/conf.d/00-unbound.conf deliberately set
these three; dropping them (silently, by omission) when switching to the
heredoc-generated config would have weakened functionality the upstream
maintainer specifically established. Restoring them keeps this change a
pure mechanism swap (bind-mount -> heredoc) rather than also being a
behavior change, which matters for getting this merged cleanly upstream.

- module-config: "validator iterator" - trims the cachedb module the
  image loads by default (unused without a configured cache backend)
- identity: "DNS" - cosmetic (only visible via CHAOS TXT id.server
  queries), but kept for parity
- root-hints: "/usr/share/dns-root-hints/named.root" - pins the image's
  own dns-root-hints package file, which it refreshes monthly via its own
  cron (/etc/periodic/monthly/dns-root-hints); without this Unbound falls
  back to whatever root hints are compiled into that unbound version,
  which then only changes when the base image itself is rebuilt

Verified empirically before making this change (comparing the previous
bind-mounted config against the image's defaults via
'unbound-checkconf -o <option>', not just reading docs):
- interface: ::1 in the old config was actually dead - do-ip6: no
  (inherited from the image, present in both old and new configs)
  suppresses all IPv6 listening regardless, confirmed via
  /proc/net/tcp6+udp6 showing no IPv6 sockets even with the old config
  loaded, so no functional loss from not carrying it over
- module-config/identity/root-hints, by contrast, all differed from the
  image defaults and are real, so those are the ones restored here

After this change: unbound-checkconf -o confirms all three now match the
old config exactly; DNSSEC validation still works (drill against a signed
test zone still returns the 'ad' flag); normal resolution still works;
the image's own healthcheck (port 5053) remains unaffected.
@springcomp

Copy link
Copy Markdown
Owner

That looks awesome.
Thanks for taking the time to document migration.

Note to @springcomp (self): consider documenting upgrade path:
https://github.com/springcomp/self-hosted-simplelogin/wiki/Upgrading
Maybe README ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants