Skip to main content
Version: 3.1.15 (stable)

Running luadch in Docker

The official image is built from this repo's docker/Dockerfile and published to GitHub Container Registry on every release tag and master push:

Tag patternSource
ghcr.io/luadch-ng/luadch:vX.Y.Zexact release
ghcr.io/luadch-ng/luadch:vX.Ylatest patch in vX.Y line
ghcr.io/luadch-ng/luadch:vXlatest minor in vX line
ghcr.io/luadch-ng/luadch:latestlatest released vX.Y.Z
ghcr.io/luadch-ng/luadch:masterbleeding-edge (post-merge, pre-release)

Available platforms: linux/amd64, linux/arm64.

Security model

The image runs as the unprivileged user luadch (UID/GID 1000) - never as root, no s6-overlay, no gosu drop-priv step. Operators who want host bind-mount files owned by their own UID override at run time with --user $(id -u):$(id -g) (or the user: key in compose).

The /defaults tree shipped in the image is world-readable so the entrypoint can copy from it under any --user override.

First-time setup

git clone https://github.com/luadch-ng/luadch.git
cd luadch
cp .env.example .env
# adjust PUID / PGID in .env if `id -u` on your host is not 1000

mkdir -p cfg scripts certs log secrets
docker compose up -d

On first start the container's entrypoint will:

  1. Seed empty mounts from /defaults: cfg/, scripts/, certs/ are populated from the bundled defaults if you mounted them empty.
  2. Generate a TLS cert by running certs/make_cert.sh if no servercert.pem / serverkey.pem exists.
  3. Log the keyprint so you can build the adcs:// URL.

Pull the keyprint from the logs:

docker compose logs luadch | grep keyprint
# [entrypoint] TLS keyprint (SHA256, base32): 4OVZAB...
# [entrypoint] share with users as: adcs://<your-host>:5001/?kp=SHA256/4OVZAB...

Replace <your-host> with the hostname / IP your users will connect to. The kp=SHA256/... parameter is the trust anchor in the DC++ ecosystem - clients pin against it instead of validating a CA chain.

First login

Nick: dummy
Password: test
Address: adc://127.0.0.1:5000 (plain)
adcs://127.0.0.1:5001/?kp=SHA256/<as logged>

After login, register yourself, delete the bootstrap account, reload:

+reg <yournick> 100
+delreg dummy
+reload

See docs/CONFIGURATION.md for the full first-run walk-through.

Mount layout

The compose file sets up six bind mounts:

Host pathContainer pathPurpose
./cfg//opt/luadch/cfg/Hub settings (cfg.tbl), encrypted user database (user.tbl)
./scripts//opt/luadch/scripts/Plugin scripts. Seeded with the bundled set; you can drop in custom plugins or override individual files.
./certs//opt/luadch/certs/TLS server cert + key. Replace with your own to skip the auto-generated self-signed pair.
./log//opt/luadch/log/Hub log files (error.log, cmd.log, ...). Also mirrored to docker logs via the entrypoint.
./secrets//secrets/AES master key for at-rest user.tbl encryption (cfg key master_key_path). Kept separate from ./cfg/ so backup tooling can split them.

The ./secrets/ separation is the F-AUTH-1 threat-model recommendation in docs/SECURITY.md: the encrypted backup of user.tbl should not live alongside the AES key that decrypts it.

Configuration changes

Edit cfg/cfg.tbl on the host with your favourite editor. Then either:

docker compose restart luadch

or, in the hub itself (after logging in as a level-100 user):

+reload

+reload is faster (no TCP listener restart) and what you'll usually want for non-network-port changes.

TLS-only deployments

Once your users are on adcs:// URLs, drop the plain port:

ports:
# - "5000:5000" # commented out
- "5001:5001"

Or restrict the plain port to localhost only (useful for +!# admin tooling that can't speak TLS):

ports:
- "127.0.0.1:5000:5000"
- "5001:5001"

IPv6

Two pieces have to line up: the container has to have a v6 stack, and the hub has to actually listen on v6.

Step 1 - enable IPv6 in the Docker daemon

/etc/docker/daemon.json:

{
"ipv6": true,
"fixed-cidr-v6": "fd00:dead:beef::/48",
"ip6tables": true
}

ip6tables: true is the critical key - without it the iptables NAT rules that publish a port to 0.0.0.0:5001 are not mirrored to the v6 stack, and [::]:5001 traffic is dropped before it reaches the container.

sudo systemctl restart docker

Step 2 - enable IPv6 on the compose-managed network

The daemon's fixed-cidr-v6 only applies to the default bridge network. Compose creates its own (<project>_default) and that one defaults to v4-only. Add a networks: block to docker-compose.yml:

networks:
default:
enable_ipv6: true
driver: bridge
ipam:
driver: default
config:
- subnet: 172.20.0.0/16
- subnet: fd00:cafe:beef::/64

The two subnets are independent - the v4 ULA stays for v4 networking, the v6 ULA gives containers a globally-unique-but-not-routable v6 address that the host's ip6tables NAT translates inbound and outbound.

After editing, re-create the network:

docker compose down
docker network rm "$(basename "$(pwd)")_default" # or: docker network ls + rm by name
docker compose up -d

# verify the container picked up a v6 address
docker compose exec luadch ip -6 addr show

You should see an eth0 line with an fd00:cafe:beef:... address.

Step 3 - configure the hub to listen on v6

The hub's cfg/cfg.tbl has separate port arrays for v4 and v6, and the listener registry in core/server.lua is currently keyed by port number only - the same port cannot bind both stacks. Use the historical layout with one port number per stack:

tcp_ports = { 5000 }, -- plain v4
ssl_ports = { 5001 }, -- TLS v4
tcp_ports_ipv6 = { 5002 }, -- plain v6
ssl_ports_ipv6 = { 5003 }, -- TLS v6

docker-compose.yml publishes all four:

ports:
- "5000:5000"
- "5001:5001"
- "5002:5002"
- "5003:5003"

For TLS-only deployments drop 5000 / 5002. Modern DC++ clients (AirDC++) understand adcs://hub.example.com:5001 for v4 and pick up adcs://hub.example.com:5003 if they need v6 - typically you publish both URLs to your users so anyone with v6 can use it.

Phase-8 candidate: dual-stack-on-the-same-port (HTTP/80 style where one number serves both v4 and v6) requires changing the _server registry to be (port, family)-keyed instead of port-only. Tracked separately for Phase-8 hardening.

Step 4 - DNS

Add an AAAA record for your hostname pointing at the host's global IPv6 address (not Docker's internal ULA - your host's outward-facing v6, the one in ip -6 addr show eth0 with scope global).

If using Cloudflare DNS: keep proxy mode off (gray cloud). The orange-cloud proxy is HTTP-only and will silently mangle ADC traffic.

# verify both records resolve
dig A hub.example.com +short
dig AAAA hub.example.com +short

Step 5 - test

From a v6-enabled box (or any free port-checker):

nc -6 -zv hub.example.com 5001
# Connection ... succeeded

Hurricane Electric's port checker is the easy web-based path if you don't have v6 connectivity locally.

Alternative: network_mode: host

If you don't want to bother with the daemon.json + compose networks setup, switch to host networking - the container shares the host's network stack directly:

services:
luadch:
image: ghcr.io/luadch-ng/luadch:latest
network_mode: host
user: "${PUID:-1000}:${PGID:-1000}"
# no ports: section in host mode
volumes:
- ./cfg:/opt/luadch/cfg
...

Trade-off: no network isolation between the container and the host. For a hub running as the box's main service, that's usually fine. Bonus: UFW rules apply normally (Docker's port-publishing iptables rules don't get involved).

Updating

⚠️ Always back up first. Operator-owned state lives in cfg/, scripts/lang/, scripts/data/, scripts/cfg/, certs/, and secrets/. None of these are touched by an image upgrade, but a clean snapshot is the safety net for any production hub.

tar -czf "luadch-backup-$(date +%F).tar.gz" cfg scripts certs secrets

Pull the new image and recreate the container; mounts persist:

docker compose pull
docker compose up -d

For vX.Y.Z releases pin the tag in docker-compose.yml so you don't get unexpected jumps:

image: ghcr.io/luadch-ng/luadch:v3.1.3

What gets updated automatically

The container's entrypoint auto-syncs the bundled top-level scripts/*.lua files from the image to your mounted scripts/ directory on every start. Bug-fixes we ship in plugin code reach your hub on the next image pull without manual action.

In addition, new bundled scripts/lang/*.lang.* files are add-only: language files that don't exist on your mount get copied in, but existing translations are NEVER overwritten. This way a release that adds i18n to a previously English-only plugin reaches non-English operators without them having to chase down the new .lang.de / .lang.fr files manually, while any operator-customized translations stay intact.

What is not touched:

PathReason
cfg/cfg.tblYour settings; new defaults are merged at runtime via the cfg.get() fallback path
cfg/user.tbl, cfg/user.tbl.bakUser database
scripts/lang/*.lang.* (existing)Your translations / MOTD customizations stay; only NEW bundled language files are added
scripts/data/*.tblPlugin runtime state (bans, regs, caches)
scripts/cfg/*.tblPer-plugin operator settings
scripts/<your-custom>.luaCustom plugins keep their distinct filenames - the auto-sync only touches files that exist in the image's /defaults/scripts/
certs/*TLS keys
secrets/master.keyAES master key

The entrypoint logs each updated file:

[entrypoint] auto-synced bundled script: cmd_nickchange.lua
[entrypoint] auto-synced 1 bundled scripts from /defaults
[entrypoint] auto-added new bundled lang file: usr_nick_length.lang.de
[entrypoint] auto-added 1 new bundled lang files from /defaults

If a bundled script's content matches what's already on disk, it is skipped (idempotent on restart). Lang files are likewise skipped when they already exist, regardless of content.

Opting out of auto-sync

If you have hand-patched a bundled script and want to preserve those edits across image upgrades, set in your .env:

LUADCH_AUTOSYNC_SCRIPTS=0

The toggle disables both the scripts/*.lua overwrite-on-diff sync and the scripts/lang/* add-only sync. Image bug-fixes and new language files stop reaching your hub automatically. You will need to copy them manually:

# Diff: which bundled scripts in your mount differ from the new image
docker compose exec luadch sh -c '
for f in /defaults/scripts/*.lua; do
n=$(basename "$f")
cmp -s "$f" "/opt/luadch/scripts/$n" 2>/dev/null \
|| echo "differs: $n"
done
'

# Find: which bundled lang files are missing on your mount
docker compose exec luadch sh -c '
for f in /defaults/scripts/lang/*.lang.*; do
n=$(basename "$f")
[ -e "/opt/luadch/scripts/lang/$n" ] || echo "missing: $n"
done
'

# Apply a single file
docker compose exec luadch \
cp /defaults/scripts/cmd_nickchange.lua /opt/luadch/scripts/
docker compose restart luadch

The recommended pattern for plugin customization is to add a custom filename rather than editing a bundled file. With that pattern the auto-sync is harmless and you can leave it ON.

Updating lang/*.lang.* after a release that changed translations

Translation files (and other scripts/<subdir>/ content) are never auto-synced because they typically contain operator-customized strings (e.g. your MOTD). When a release changes a bundled translation key that you have not customized, the in-script lang.foo or "<fallback>" pattern keeps the hub running on the hardcoded fallback - you just miss the new translation until you sync.

To sync a specific lang file, diff against the image and merge what matters:

# Show your file vs the image's
docker compose exec luadch \
diff /opt/luadch/scripts/lang/etc_motd.lang.en \
/defaults/scripts/lang/etc_motd.lang.en

# Apply the image version (will overwrite your customizations!)
docker compose exec luadch \
cp /defaults/scripts/lang/etc_motd.lang.en /opt/luadch/scripts/lang/
docker compose restart luadch

Release notes call out lang/data file changes explicitly when they happen so you know to sync.

Troubleshooting

Permission denied on bind mounts

Symptom: container exits with cannot create '/opt/luadch/log/...': Permission denied.

Cause: host directory not owned by the runtime UID. The image runs as 1000:1000 by default; if your host user is 1001 the bind-mount directories are 1001-owned and the container user can't write to them.

Fix:

# either set PUID/PGID to your host user
echo "PUID=$(id -u)" >> .env
echo "PGID=$(id -g)" >> .env
docker compose up -d

# or chown the dirs to the container's default 1000
sudo chown -R 1000:1000 cfg scripts certs log secrets

Lost the keyprint

The entrypoint logs the keyprint on every start; restart the container to see it again:

docker compose restart luadch
docker compose logs luadch --tail 20 | grep keyprint

Or compute it directly from the cert on the host:

openssl x509 -in certs/servercert.pem -noout -fingerprint -sha256 \
| sed 's/.*=//' | tr -d ':' | tr 'A-F' 'a-f' \
| xxd -r -p | base32 | tr -d '='

docker logs shows nothing after a while

The hub writes to log/error.log and log/cmd.log on disk; the entrypoint forwards them via tail -F. If you docker compose restart, you'll see the most recent lines plus everything written afterwards. The on-disk log files persist independently in ./log/.

Self-signed cert rejected by my client

DC++ clients trust by keyprint, not by CA chain. If your client is complaining about an unverified cert, check that the adcs:// URL you gave it actually includes the ?kp=SHA256/<hash> suffix. Without it the client falls back to PKI validation which a self-signed cert won't pass.

If you already replaced the cert with one from a public CA, drop the keyprint suffix from the URL.

Building the image yourself

docker build -f docker/Dockerfile -t luadch:dev .
docker run --rm -d --name luadch-dev \
--user $(id -u):$(id -g) \
-p 5000:5000 -p 5001:5001 \
-v "$(pwd)/dev/cfg:/opt/luadch/cfg" \
-v "$(pwd)/dev/scripts:/opt/luadch/scripts" \
-v "$(pwd)/dev/certs:/opt/luadch/certs" \
-v "$(pwd)/dev/log:/opt/luadch/log" \
-v "$(pwd)/dev/secrets:/secrets" \
luadch:dev

For multi-arch test builds, set up buildx and target the platforms your release would publish:

docker buildx create --use
docker buildx build --platform linux/amd64,linux/arm64 \
-f docker/Dockerfile -t luadch:dev .