Running ANetBBS in Docker

ANetBBS ships two Docker deployment options alongside the traditional
install.sh/systemd path (which is still the primary, most-tested way
to run a production BBS). Neither replaces the other:

  • Single-container quick start — one image, one container, every
    service (web, terminal, MRC bridge, finger, binkp) managed inside it
    by supervisord. Fastest way to try ANetBBS, and the best place to
    start if this is your first time with any of this.
  • docker-compose — the "correct" way — one container per service,
    matching the 5 systemd units a traditional install runs. Proper
    per-service health checks, logs, and restart control. This is the
    recommended option for anything beyond kicking the tires — do the
    single-container test first to confirm the basics work, then move
    to this.

Both share one image (docker/Dockerfile) — the only difference is
which entrypoint/command each container runs.

Pre-built multi-arch images (amd64 + arm64) are published to GHCR on
every tagged release
— ghcr.io/anetonline/anetbbs:latest, or pin a
version like :v1.1.2. That's what .env.docker.example already
points at by default, so most people never need to build anything
locally. The walkthrough below still shows the local-build path too
(docker build), since that's what you want if you're testing a
change to the source rather than running a release as-is.

A transparency note on how tested this path is: the single-container
quick start has now been run end-to-end against a real Docker daemon
(build, run, all 5 services reaching RUNNING under supervisord, web
+ terminal + MRC web + MRC terminal all confirmed working) — that real
test caught and fixed three bugs the mocked unit tests couldn't have
(wrong entrypoint path, a broken terminal-service Python import, and
MRC web chat's WebSocket URL construction). Also confirmed working on
Windows via Docker Desktop, including a full fresh install and the
initial admin login. The multi-container docker-compose path is still
unit-tested with mocked Docker/subprocess calls only, not yet run
end-to-end against a real Docker daemon — if you hit something there
that doesn't match what's documented, please report it.

If you're new to Docker — a few concepts first

  • An image is a packaged snapshot of the application (code +
    dependencies). A container is a running instance of an image —
    like the difference between a program's installer and the program
    actually running.
  • docker build turns source code into an image. docker run (or
    docker compose up) starts a container from an image.
  • Anything a container writes to its own filesystem is thrown away
    when the container is removed, unless it's inside a volume —
    a piece of storage that lives outside the container and survives
    docker stop/docker rm. This is why the commands below always
    mount a volume for data/ (your database, uploads, etc.) — deleting
    the container never deletes your data, but deleting the volume
    does.
  • -p 5000:5000 means "make port 5000 on my machine forward to port
    5000 inside the container." The first number is your machine, the
    second is inside the container.

One-time setup: run docker without sudo

If docker ps gives you a permission error, your user isn't in the
docker group yet:

sudo usermod -aG docker $USER

Close your terminal and open a new one — group membership changes
don't apply to already-open sessions. Confirm it worked:

docker ps

An empty table (just column headers, no permission error) means
you're good.

Step 1 — get the image

Easiest: pull the published image, nothing to build.

docker pull ghcr.io/anetonline/anetbbs:latest

Testing a local change instead? Build from source from the root of
your ANetBBS source checkout:

docker build -f docker/Dockerfile -t anetbbs:local .

The first build takes a few minutes (compiling a few Python
dependencies that don't have prebuilt wheels for every platform). If
it fails partway through, that's useful — it means something in the
Dockerfile needs adjusting; save the full output. The rest of this
walkthrough uses anetbbs:local in docker run/.env — swap in
ghcr.io/anetonline/anetbbs:latest (or a pinned version tag) instead
if you pulled rather than built.

Step 2 — try the single-container quick start

cp .env.docker.example .env

Edit .env in a text editor and fill in at least SECRET_KEY (any
random string), BBS_NAME, SYSOP_NAME, BBS_EMAIL.

Real gap found live (single-container mode on Windows/Docker Desktop):
unlike the data/ volume above, the MRC chat bridge's own config file
has no equivalent mount here at all, so it silently falls back to
mrc/bridge/config.example.json's placeholder values ("My BBS", a
blank sysop name) no matter what you put in .env — BBS_NAME/
SYSOP_NAME only drive the rest of the BBS, not the MRC bridge, which
reads its own separate file. Prepare one now, same as the
docker-compose setup in Step 3 does:

cp /path/to/anetbbs-source/docker/compose/mrc-bridge-config.json.example mrc-bridge-config.json

Edit mrc-bridge-config.json and set at least bridge_bbs and
bbs_sysop to your real BBS name/sysop alias — these are what MRC
chat actually advertises to other bridges, independent of .env.

docker run -d --name anetbbs \
    --env-file .env \
    -e ANETBBS_RUNTIME=docker-single \
    -e ANETBBS_DB_URL=sqlite:////app/data/anetbbs.db \
    -e DATABASE_URL=sqlite:////app/data/anetbbs.db \
    --cap-add NET_BIND_SERVICE \
    -p 5000:5000 -p 2233:2233 -p 2234:2234 \
    -p 18:18 -p 11:11/udp -p 79:79 -p 8080:8080 -p 24554:24554 \
    -v anetbbs_data:/app/data \
    -v "$(pwd)/mrc-bridge-config.json:/app/mrc/bridge/config.json:ro" \
    anetbbs:local \
    /usr/local/bin/entrypoint-single.sh

Watch it start:

docker logs -f anetbbs

Ctrl-C to stop watching (the container keeps running in the
background either way). Confirm all 5 services actually came up:

docker exec anetbbs supervisorctl -c /app/docker/single/supervisord.conf status

You should see all 5 as RUNNING:

web           RUNNING   pid 8, uptime 0:02:15
terminal      RUNNING   pid 9, uptime 0:02:15
mrc-bridge    RUNNING   pid 10, uptime 0:02:15
finger        RUNNING   pid 11, uptime 0:02:15
binkp         RUNNING   pid 12, uptime 0:02:15

If any show FATAL/BACKOFF instead, check that specific service's
output:

docker exec anetbbs supervisorctl -c /app/docker/single/supervisord.conf tail web

Now actually use it:
- Web admin: open http://localhost:5000 in a browser.
- Telnet: telnet localhost 2233
- SSH: ssh -p 2234 anyuser@localhost

Logging in as admin for the first time

Not obvious from a container, so worth calling out explicitly: nothing
above created an admin account for you interactively. The first time
web boots against an empty database, ANetBBS bootstraps a fallback
account (username: admin, a random password) exactly like a bare
install.sh install does when nothing else has created one first — it
gets logged as a WARNING on that very first boot, and the password is
also written to admin_password.txt inside the data/ volume.

Find it either way:

docker logs anetbbs 2>&1 | grep -A3 "INITIAL ADMIN ACCOUNT CREATED"

or, if you missed that first-boot log line (it only prints once, the
moment the account is created):

docker exec anetbbs cat /app/data/admin_password.txt

Log in at http://localhost:5000 (or over telnet/SSH) as admin with
that password, then change it immediately — Admin → My Account
(web) or the account-settings menu (terminal). This fallback account
only ever gets created once, the first time the database has zero
admin accounts at all — there's no interactive wizard prompt in a
container boot the way install.sh has, so it's unavoidable on a
truly fresh install. If you'd rather use your own chosen username day
to day, register that account through the normal new-user signup, log
back in as admin and promote it to admin from the web admin's Users
page (or anetbbs-cfg's Users section over SSH), then optionally
demote or delete the original fallback admin account.

Cleaning up

docker stop anetbbs && docker rm anetbbs

Your data survives this — it's in the anetbbs_data volume, not the
container. Only delete the volume if you want a completely fresh
start (this DOES delete your database/uploads):

docker volume rm anetbbs_data

Step 3 — the docker-compose setup (recommended once the above works)

mkdir ~/anetbbs-docker-test && cd ~/anetbbs-docker-test
cp /path/to/anetbbs-source/docker/compose/docker-compose.yml .
cp /path/to/anetbbs-source/.env.docker.example .env
cp /path/to/anetbbs-source/docker/compose/mrc-bridge-config.json.example mrc-bridge-config.json

Edit .env:
- Fill in SECRET_KEY, BBS_NAME, SYSOP_NAME, BBS_EMAIL as before.
- ANETBBS_IMAGE/ANETBBS_IMAGE_TAG already default to the published
ghcr.io/anetonline/anetbbs:latest — only change these if you built
locally in Step 1 instead and want to run that image:
ANETBBS_IMAGE=anetbbs ANETBBS_IMAGE_TAG=local
- See the MRC_BRIDGE_HOST warning below before you skip past it.

docker compose up -d
docker compose ps

Give it 15-30 seconds, then docker compose ps should show every
service as healthy. Useful commands from here:

docker compose logs -f web       # tail one service's logs
docker compose logs -f           # tail all of them
docker compose restart web       # restart one service
docker compose down              # stop + remove containers (data volume survives)

Same fallback admin account as the single-container path (see
"Logging in as admin for the first time" above) — just compose-style
commands to find the password:

docker compose logs web | grep -A3 "INITIAL ADMIN ACCOUNT CREATED"
# or:
docker compose exec web cat /app/data/admin_password.txt

Enabling rlogin / FTP

Both default off (matching install.sh's own default-off posture).
Uncomment their ports: lines in docker-compose.yml's terminal
service and set RLOGIN_ENABLED=true / FTP_ENABLED=true in .env.
FTP passive mode doesn't yet support NAT traversal in this
codebase (no masquerade_address support) — under Docker's default
bridge networking, passive FTP transfers will likely fail. If you need
FTP working today, run the terminal container with network_mode: host instead, which sidesteps Docker's NAT entirely.

Enabling native umrc-client support

Off by default (matching install.sh's own default-off posture). Set
"mrc_tcp_enabled": true in mrc-bridge-config.json, and change
"mrc_tcp_listen_host" from its default "127.0.0.1" to "0.0.0.0"
— loopback-only means loopback inside the container's own network
namespace
, which a published port can't reach at all, unlike on a
bare-metal install where "loopback" and "this host" are the same
thing. Then uncomment the mrc-bridge service's 5010:5010 ports:
line in docker-compose.yml. See docs/SECURITY.md for what this
listener's security posture actually is before opting in — it's brand
new, less-reviewed surface than the rest of the bridge.

The docker.sock trust boundary (read this before enabling)

The web service's compose entry mounts /var/run/docker.sock into
the container so the Sysop Control Panel can restart/upgrade sibling
containers via the Docker API instead of systemctl (which doesn't
exist inside a container at all). This grants root-equivalent
control of the Docker host to anything that can reach the panel's
restart/upgrade endpoints.
It's the same tradeoff tools like
Portainer and Watchtower make, and it's why those endpoints are
already gated behind @login_required/admin-only — but it's worth
understanding plainly before you opt in.

If you'd rather not accept that: delete the /var/run/docker.sock
volume line from the web service. Everything else keeps working —
the panel just won't be able to restart/upgrade sibling containers for
you; run docker compose restart <service> / docker compose pull && up -d by hand instead.

A future hardening option (not implemented, documented for later): put
a scoped proxy like tecnativa/docker-socket-proxy between the web
container and the real socket, exposing only the specific API
operations the panel needs (containers list/start/stop/restart,
images pull) instead of the full Docker API.

MRC_BRIDGE_HOST — a real gotcha

Set this in .env to your public domain, not the compose service
name (mrc-bridge). The browser connects to the MRC bridge's
WebSocket directly — it isn't proxied through Flask — so setting
this to a Docker-internal service name (unreachable from outside the
Docker network) will silently break MRC chat for web users even though
everything looks fine in docker compose ps.

Single-container mode doesn't need MRC_BRIDGE_HOST set at all —
there's no nginx in that image, so the web page automatically builds
the bridge WebSocket URL using your browser's own hostname with the
bridge's port (8080 by default) swapped in, instead of assuming an
nginx proxy exists on the same port as the web app. This only kicks in
when ANETBBS_RUNTIME=docker-single (set by the documented docker run command above) — leave MRC_BRIDGE_HOST unset/default for this
mode.

One consequence either way (single-container OR compose): because the
browser talks to the bridge directly, on port 8080, the bridge
itself has no login check of its own — the only place MRC web access
is gated behind your BBS login is nginx's auth_request on a
traditional install. If port 8080 is reachable from the open internet
in either Docker mode, anyone who finds that port can join MRC chat
without ever logging into your BBS. If that matters for your
deployment, don't publish port 8080 to a public interface (bind it to
127.0.0.1 only, or put a real reverse proxy in front of it that adds
its own auth gate) — this is a pre-existing tradeoff of the "direct
bridge connection, no nginx" path, not something new to fix.

Why this is safe with SQLite (no Postgres required)

ANetBBS today runs as 5 separate OS processes (one per systemd unit)
that all open their own connection to the same data/anetbbs.db
SQLite file. That's exactly the same shape as 5 separate containers
doing the same thing — and it's safe under Docker for the identical
reason it's safe on bare metal: every container runs as the same
fixed user (UID 1000)
and shares one local Docker volume for
data/. WAL mode + a busy-timeout are enabled at the SQLAlchemy engine
level (anetbbs/models.py) so concurrent writers retry instead of
immediately failing under brief contention.

Do not back the anetbbs_data volume with NFS, CIFS, or any other
network filesystem — that's the one case where SQLite's file-locking
guarantees genuinely break down. A plain local Docker volume (the
default local driver, which is what the commands above use) is the
only supported backing store.

Postgres is mentioned in some docstrings as theoretically supported,
but there's no driver dependency and no tested migration path in this
codebase today — don't treat it as a real option for a container
deployment yet.

What's not supported inside containers yet

These all degrade gracefully (the existing code already logs a clear
"skip" message rather than crashing) — none of them break the rest of
the BBS, they just won't work:

  • DOSBox / Mystic BBS / Node.js door games — none of these runtimes
    are bundled in the image. GAMES_ENABLED stays on; DOS/Mystic/Node
    door types specifically report themselves unavailable.
  • ClamAV virus scanning — shells out to a local clamscan binary,
    not bundled in the image, and there's no ClamAV-daemon-over-TCP
    client implemented yet for a sidecar-container setup.
  • .lzh archive extraction (lhasa) and sixel images in the
    terminal RSS reader (libsixel-bin)
    — same story, not bundled.
  • FTP passive-mode NAT traversal — see above.

Self-upgrade

docker-compose mode supports one-click self-upgrade from the admin
panel (Admin → Upgrades), same UI as bare metal. Behind the scenes it
launches a detached, ephemeral sibling container (from the same image,
which bundles the docker CLI + compose plugin) that runs docker compose pull && up -d --remove-orphans against your own
docker-compose.yml/.env — the container analogue of the
systemd-run --scope cgroup-escape trick bare-metal installs use so
the upgrader survives the web container being recreated mid-upgrade.

This needs ANETBBS_COMPOSE_DIR set in .env to the absolute host
path containing your docker-compose.yml (defaults to wherever you
ran docker compose up from, which is usually right — only override
if you launch compose from a script/cron job with a different working
directory each time). Only relevant when running the published GHCR
image (the default) — not applicable while you're pointing .env at
a locally built anetbbs:local image, since there's no new tag to
pull.

Single-container mode has no in-place upgrade button — clicking
"Install Update" there returns a clear message instead of attempting
anything, since a container replacing its own single running image is
a meaningfully harder problem than recreating a sibling container.
Pull the new tag and recreate the container by hand instead (same
docker stop && docker rm + re-run the docker run command from
Step 2 above, with a new image tag).

Multi-arch

Handled automatically — every version tag push builds and publishes
linux/amd64 + linux/arm64 images to GHCR via
.github/workflows/docker-build.yml,
no manual step needed. The command that workflow runs, for reference
(or if you're pushing to your own registry/fork instead):

docker buildx build --platform linux/amd64,linux/arm64 \
    -f docker/Dockerfile -t your-registry/anetbbs:yourtag --push .

For local-only testing (Step 1 above), a plain docker build targets
just your own machine's architecture, which is all you need to test on
one box.

Ports reference

See docs/PORTS.md for the full port list and privileged-port
background. Container-specific notes:

  • Finger (79) binds to an unprivileged port (1079) inside its
    container and gets remapped to 79 via Docker's port publish — no
    cap_add needed for that container at all.
  • MSP (18) and SYSTAT (11, UDP) stay bound to their real ports
    inside the web container, which needs cap_add: [NET_BIND_SERVICE]
    in compose (mirrors the bare-metal systemd unit's
    AmbientCapabilities=CAP_NET_BIND_SERVICE). If your platform doesn't
    allow cap_add, override MSP_PORT/SYSTAT_PORT in .env to
    unprivileged values and remap them the same way Finger does.

Troubleshooting

  • docker: permission denied — you're not in the docker group
    yet, or you added yourself but didn't open a new terminal. See "One-
    time setup" above.
  • Build fails on a specific pip package — save the full output and
    report it; some dependency may need a from-source build on your
    specific CPU architecture, which can need an extra system package.
  • A service shows FATAL/BACKOFF in supervisorctl status —
    run supervisorctl ... tail <program> (single-container) or docker compose logs <service> (compose) to see why it's crash-looping.
  • Port already in use — something else on your machine (maybe a
    bare-metal ANetBBS install!) is already using that port. Either stop
    the other thing or change the host side of the -p host:container
    mapping (e.g. -p 5001:5000 to use 5001 on your machine instead).
  • Can't reach the web UI / telnet from another device on your
    network
    — confirm the port mappings are actually published (docker ps shows them in the "PORTS" column) and that your machine's
    firewall allows those ports.