Docker
easywall filters the host’s firewall, so it runs on the machine you want protected — which is usually not the machine you are browsing from. Everything below assumes that: commands on the server, browser on your own machine.
First run
On the server, over SSH:
git clone https://github.com/jp1337/easywall.git
cd easywall
docker compose up -d
Then, from your own machine, open https://<server>:12227 — the address you
already reach that host on. localhost only works when easywall is on the
machine in front of you, and it is the one instruction this page used to give.
Five things to expect on that first page:
| A setup token field | docker compose logs easywall \| grep 'setup token' \| tail -1 — see First Run |
| A certificate warning | easywall generates its own on first start. Accept it, or supply your own |
| Nothing filtered yet | a fresh container carries no rules, so nothing easywall did is between you and port 12227 |
The container reads unhealthy |
correct, and it clears at your first apply — see Health check |
| This host’s containers | already trusted on a fresh install — docker.enabled = true is the default since 2.22; a file from 2.21 keeps what it says; see Docker Coexistence to turn it off or change what it accepts |
If the page does not load at all, easywall is not what is blocking it. Check the host’s existing firewall and any provider-level security group for port 12227.
Now complete the setup — it
covers the SSH port, which is the one answer that can shut you out. Before you do,
see Environment Variables for what
docker-compose.yml can set without editing ./easywall-config at all — and what it
deliberately cannot.
./easywall-configis yours, and changes owner on first start. Docker creates it empty. The entrypoint fills it from the defaults inside the image, in the shape the Debian package installs.web.tomlandssl/go to the container’seasywalluser (uid 100, gid 101);easywall.tomlgoes to root. It is gitignored: the account lives there. Editing those files on the host needssudo. In a bind-mounted/var/lib/easywallthe entrypoint does the same: the directory is root’s, and the web process keeps its passkeys and TOTP replay store inweb/inside it.
Upgrading a git checkout from 2.21 or earlier? compose mounted
./config, the tracked defaults. Move your files once, before pulling:docker compose down sudo mv config easywall-config && sudo rm -f easywall-config/embed.go git checkout -- config && git pull docker compose up -dYour existing
easywall.tomlis moved as-is: whatever it already says fordocker.enabledis kept, not replaced by the new default. Skip it and the container starts a fresh first run — which the setup token keeps anyone else from claiming.
Reaching it from elsewhere
Direct access — https://<server>:12227 from your own machine — is the
intended path. It is safe to leave open: /firstrun now needs the setup
token from the container’s own log, not merely being first there. Two more
routes, if a firewall or a provider security group closes that port for
everyone but you:
| Route | Command | Watch for |
|---|---|---|
| An SSH tunnel | ssh -L 12227:127.0.0.1:12227 user@server, then open https://127.0.0.1:12227 |
administratively prohibited names sshd’s AllowTcpForwarding no — not an easywall fault |
| A reverse proxy | see Behind a Reverse Proxy | a proxy address that is SNAT’d or on an overlay network needs its real address read from the audit log and listed in trusted_proxies |
What must persist
| Path in the container | Holds | Without it |
|---|---|---|
/etc/easywall |
easywall.toml, web.toml, the certificate |
the setup starts over |
/var/lib/easywall |
rules.json, the apply state, passkeys |
every rule is gone |
/var/log/easywall |
audit.log, packets.log (with persist = true) |
the audit trail and the refused-packet history start empty at every update |
docker run -d --name easywall --network host --cap-add NET_ADMIN \
-v easywall_config:/etc/easywall \
-v easywall_data:/var/lib/easywall \
-v easywall_logs:/var/log/easywall \
ghcr.io/jp1337/easywall:latest
Without the mount, Docker’s VOLUME directive gives every recreated container a
new, empty anonymous volume, and the history in it is gone after the next update.
The health check
The image carries a HEALTHCHECK that fetches /healthz every ten seconds, and
docker-compose.yml carries the same check under healthcheck:. Since 2.19
there are two definitions rather than one, because podman’s default image
format has no place to put the first — see Podman below. A test
keeps them identical.
docker compose ps # healthy / unhealthy
docker exec easywall easywall-core health
It reports unhealthy while the firewall is not filtering — a fresh container, a deleted table, a dead web process, or a core that stopped answering. That is new: for a year the check looked at the core’s socket, so a container whose interface had died stayed Up.
Two consequences worth knowing before you meet them:
depends_on: condition: service_healthyon easywall waits for ever on a container that has never applied rules. Apply once, or drop the condition.- A
degradedfirewall stays healthy. It is still filtering, and a restart fixes no cause of it. Health Check has the states, the exit codes, and how to let a monitoring host read/healthz.
Podman
podman compose up -d needs nothing extra. docker-compose.yml declares its
own healthcheck: block since 2.19, so the container gets one whether or not the
image does.
A plain podman build + podman run still needs --format docker:
podman build --format docker -t easywall .
podman run -d --network host --cap-add NET_ADMIN easywall
Without it, the image carries no health check and the build still succeeds.
OCI is podman’s default image format and has no healthcheck field, so
podman build exits 0 and leaves HealthCheck: null. A plain podman run
has no compose file to fall back on here. Check what you got:
podman image inspect --format '{{ .HealthCheck }}' easywall
supervisorctl works inside the container, over a root-only socket, if you need
to stop or start one process without restarting both:
docker exec easywall supervisorctl status
Where to pull from
The same linux/amd64 + linux/arm64 image, pushed to all three by CI. Public, no
authentication.
| Registry | Image |
|---|---|
| GitHub Container Registry | ghcr.io/jp1337/easywall |
| Docker Hub | docker.io/kermit1337/easywall |
| Quay.io | quay.io/jp1337/easywall |
Quay is behind. The publishing token is being rejected, so that mirror is skipped until it is replaced, and it does not have the recent releases. Use GHCR or Docker Hub.
Which tag
| Tag | Moves | For |
|---|---|---|
:latest |
on stable releases only — a release candidate does not move it | production |
:vX.Y.Z |
never | pinning, e.g. v2.25.0 |
:edge |
after every green build on main |
tracking development, demo mode |
:sha-<commit> |
never | rollback and debugging |
# docker-compose.override.yml — pin a version
services:
easywall:
image: ghcr.io/jp1337/easywall:v2.25.0
Why host networking
nftables acts on the host’s network namespace. In its own namespace the container
would filter only its own traffic, which is not what you asked for. NET_ADMIN is
what lets it issue the netlink calls that reach host tables.
network_mode: host
cap_add:
- NET_ADMIN
security_opt:
- no-new-privileges:true
NET_ADMIN and nothing beyond it. The shipped compose file also asked for
SYS_MODULE — the capability to load kernel modules. From a container that
already shares the host’s network, that capability is host root under another
name. This page never listed it. It is gone; applying a full rule set was verified without it. If
nf_tables is not loaded, load it on the host with modprobe nf_tables — a host
already running nftables has it.
The packet log’s nfnetlink_log needs the same kind of load, and gets it the
same way. The kernel requests it by name the first time an NFLOG group
binds, triggered by nothing more than the NET_ADMIN this container
already has. Only a host with module loading disabled entirely falls back
to the kernel log instead.
This is also why easywall in a container still coexists with Docker’s own rules —
it owns table inet easywall and nothing else.
Needs
- Docker Engine 24+, Compose v2
- A Linux host with nftables
Your own certificate
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./easywall-config:/etc/easywall
# ./easywall-config/web.toml
[tls]
cert = "/etc/letsencrypt/live/example.com/fullchain.pem"
key = "/etc/letsencrypt/live/example.com/privkey.pem"
tls.acme is not supported in this image. The web process drops to an
unprivileged user with no path to reacquire CAP_NET_BIND_SERVICE.
no-new-privileges:true is deliberate hardening this project will not trade
away for one feature. Run your own ACME client (certbot, or Let’s Encrypt’s
own container) against the host and mount its output the way shown above
instead.
Passkeys need
tls.hostnametoo. It is the WebAuthn Relying Party ID, set separately from the certificate above — see Configuration →[tls].
Updating
docker compose pull && docker compose up -d
A recreate restores whatever rules were last applied, with no acceptance window — nothing catches a restore that would lock you out, the way an apply through the interface does. Pull and recreate on purpose, not on a timer.
For production, pin :vX.Y.Z (see Which tag) and update the
same way. Watchtower, the usual way to
automate this, is archived with no release since 2023 — skip it, or point it
at nothing looser than a pinned tag you bump yourself.
Checking what you pulled
Every image carries the source commit in an OCI label:
docker pull ghcr.io/jp1337/easywall:latest
docker image inspect \
--format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \
ghcr.io/jp1337/easywall:latest
This used to read
.Manifest.Annotations, where the value has never been. A label goes into the image config; manifest annotations are a different field that only carries base-image and creation keys. Checked by building an image with--label org.opencontainers.image.revision=abc123and reading the pushed OCI layout:manifest annotationsheldimage.base.nameandimage.created, andabc123was inconfig.Labels. So the command printed nothing and looked like an image with no provenance. Release images genuinely had none either — the labels are set in theDockerfilenow, which is the one file all three build paths share.
Compare it against the commit the release tag points at. The workflows that publish
these images are
release.yml
and
publish-edge.yml.