Installing Blocklist
Two images: the API, and an optional console. The API answers on 5441, the console on 5443.
Deploying it
You need Docker with the Compose plugin and an API key of your own choosing (any long random
string, for example from openssl rand -base64 32).
Save this as docker-compose.yml:
services:
blocklist:
image: ghcr.io/antelao-io/blocklist:${BLOCKLIST_VERSION:?set BLOCKLIST_VERSION}
restart: unless-stopped
ports:
- "5441:8080"
environment:
Api__Keys__ui: ${BLOCKLIST_API_KEY:?set BLOCKLIST_API_KEY}
BLOCKLIST_SEED_ADMIN_PASS: ${BLOCKLIST_SEED_ADMIN_PASS:-}
BLOCKLIST_SEED_ADMIN_TOTP: ${BLOCKLIST_SEED_ADMIN_TOTP:-}
volumes:
- blocklist-data:/data
- /etc/machine-id:/etc/machine-id:ro
blocklist-ui:
image: ghcr.io/antelao-io/blocklist-ui:${BLOCKLIST_VERSION:?set BLOCKLIST_VERSION}
restart: unless-stopped
profiles: [console]
ports:
- "127.0.0.1:5443:8080"
environment:
BlocklistApi__BaseUrl: http://blocklist:8080
BlocklistApi__ApiKey: ${BLOCKLIST_API_KEY:?set BLOCKLIST_API_KEY}
DataProtection__KeysPath: /keys
volumes:
- blocklist-ui-keys:/keys
depends_on:
blocklist:
condition: service_healthy
volumes:
blocklist-data:
blocklist-ui-keys:
Put the values in a .env file beside it:
BLOCKLIST_VERSION=<the release you run>
BLOCKLIST_API_KEY=<your API key>
BLOCKLIST_SEED_ADMIN_PASS=<the console administrator's password>
BLOCKLIST_SEED_ADMIN_TOTP=<optional: the administrator's authenticator secret>
Then start the API alone, or the API and the console:
sudo docker compose up -d
sudo docker compose --profile console up -d
- The version is pinned. Both images carry the same release number. Keep a release number in
BLOCKLIST_VERSIONrather thanlatest, which moves the moment a new release is published, so that you can always say what is deployed. The releases, and what each one changed, are on Release notes; a registry tool lists the tags, for exampleskopeo list-tags docker://ghcr.io/antelao-io/blocklist. To upgrade, change the number and runsudo docker compose pulland theup -dcommand again. Check the images' signatures too: see Verify a download. - The API is published on every interface in this example, over plain HTTP. Decide who may reach it before you start it: see Keeping the API key off the network.
- The console is published on loopback only. It is a sign-in page for people, not an API for
machines. Reach it over an SSH tunnel (
http://localhost:5443), or put a reverse proxy with HTTPS in front of it before publishing it anywhere else - passkeys work only over HTTPS, and the console keeps a sign-in only over HTTPS or onlocalhost. Sign in as admin withBLOCKLIST_SEED_ADMIN_PASS, and a code from the authenticator app whenBLOCKLIST_SEED_ADMIN_TOTPis set, then add accounts for everyone else on the Users page; see Accounts. - Behind a reverse proxy, name it with
Ui__TrustedProxies__0(its address, or its network). Otherwise the console sees every request as coming from the proxy over plain HTTP: every sign-in is throttled and audited under the proxy's address. With none named, a forwarded address is believed from nobody, so a caller cannot name its own. A proxy in front of the API itself is named the same way withApi__TrustedProxies__0. See A proxy that is not trusted. - The key the console uses is named
uiin the example. If yours has another name, setApi__ConsoleKeyNameto it, or the audit log records the console's address for everyone who signs in through it. - Keep the
blocklist-datavolume across an upgrade. It holds the service's database - your own entries and exceptions, the lists' settings and the copies downloaded, the jobs, the issued keys, the accounts and the audit log - and the license. Losing it loses your entries and accounts, drops the installed license, and downloads every list again. /etc/machine-idis mounted because the license is bound to the host, not the container. Without it every recreated container looks like a different machine.
Api__Keys__<name> in the API container's environment. Everything except
/health needs the X-Api-Key header, and a service with no configured key
cannot be reached at all. See API keys.
Outbound access
The service downloads each public list from its publisher, over HTTPS, on the list's schedule. Let
the API container reach the hosts each list's card names in the console: the Source line of
every card on the Blocklists page, which GET /api/v1/sources returns as sources for each
list too. Only the lists your license covers are shown and downloaded, so the list of hosts follows
the license.
Two more things go out:
- The mail-domain check asks DNS, through the resolvers the container is given, for the
domain's mail servers. When DNS does not answer in time or answers with a failure, that check says
unknownrather than yes or no; see The mail-domain check. - The license check-in, once a day. See Licensing.
A download that cannot reach its host fails as a job, and the list keeps the entries it already had. Nothing that calls the checks goes down because a list could not be refreshed.
Keeping the API key off the network
The API has no TLS of its own. Every call sends the API key in the X-Api-Key header, so on a
network you do not control anyone who can see the traffic can read the key and use it.
A stolen key can read and change your entries and your lists, and read the firewall log.
Publish the API no wider than its callers need. The console reaches it over the compose network
(http://blocklist:8080), so none of these changes affects the console.
| Who calls the API | What to do |
|---|---|
| Only programs on this host | Publish it on loopback: "127.0.0.1:5441:8080" |
| Machines on a private network | Publish it on this host's private address only, for example "10.0.0.5:5441:8080", and admit only the callers in the host firewall. Docker routes published ports past the host's INPUT rules, so filter them in the DOCKER-USER chain |
| Machines across the internet | Put a reverse proxy with HTTPS in front of it, publish the API on loopback for the proxy to reach, and name the proxy as trusted |
With a reverse proxy on the same host, the API side of the compose file becomes:
blocklist:
ports:
- "127.0.0.1:5441:8080"
environment:
Api__TrustedProxies__0: 172.18.0.1
The proxy forwards to http://127.0.0.1:5441 and sends X-Forwarded-For and X-Forwarded-Proto.
The address to trust is the one the API sees the proxy's requests come from: for a proxy on the same
host that is the gateway of the compose network (docker network inspect <network>). A wrong or
missing entry shows itself, as the next section describes.
Who may reach each part of the API can also be narrowed inside the service, door by door: see Firewall and threat protection.
A proxy that is not trusted
When a proxy on the host's own networks (loopback, or a private address) forwards requests to the console or the API without being named as a trusted proxy, every visitor behind it is judged as the proxy: they share one sign-in limit, and the audit log and the session list show the proxy's address instead of theirs. The console then shows Administrators a notice on every page:
- which address forwards, whether the console or the API saw it, how many requests, and when it was first and last seen;
- what to set:
Ui__TrustedProxies__0on the console, orApi__TrustedProxies__0on the API, with the address it names. Restart that container afterwards; - red when the address is loopback (
127.0.0.1or::1): that is a sidecar or a proxy in the same network namespace, for example peer encryption on Azure Container Apps. Amber for any other private address, which may also be your own forward proxy; trusting it silences the notice.
The notice has no close button. It goes away 24 hours after the proxy was last seen, so once the proxy is trusted it lapses on its own.
The first time a loopback proxy is seen, the service also sends one mail to the recipients on the SMTP page. While the mail cannot be handed to the mail server (no SMTP configured, or the mail server refuses it), it is tried again every 15 minutes, and the notice says so. Once it is handed over it is never sent again, also after a restart.
A request through an SSH tunnel carries no forwarded address, so it never raises the notice.
Entries from the deployment
Addresses and domains the deployment itself refuses can be set in the API container's environment,
Blocklist__Entries__0, Blocklist__Entries__1 and on: a full mail address, or @domain for a
domain together with its subdomains. One value may also hold several, separated by commas or
semicolons. They are checked like any entry of your own and shown locked on the Blocklists
page: they change with the deployment, not through the console or the API. See
Your own entries.
Cloudflare Turnstile
Cloudflare Turnstile confirms that a sign-in comes from a person's browser - it weighs the browser and how it behaves, usually without a puzzle - before the password is even looked at. The console's sign-in page can carry it, so a password or passkey sign-in is accepted only from a browser that passed it. It is off until both of its keys are set, and a console with no internet access leaves it off.
- In the Cloudflare dashboard, add a Turnstile widget for the hostname people open the console on, and copy its site key and secret key.
- Add them to the console's
environmentin the compose file, and put the values in.envasBLOCKLIST_TURNSTILE_SITE_KEY,BLOCKLIST_TURNSTILE_SECRET_KEYand, optionally,BLOCKLIST_TURNSTILE_HOSTNAME:
blocklist-ui:
environment:
Turnstile__SiteKey: ${BLOCKLIST_TURNSTILE_SITE_KEY:-}
Turnstile__SecretKey: ${BLOCKLIST_TURNSTILE_SECRET_KEY:-}
Turnstile__Hostnames__0: ${BLOCKLIST_TURNSTILE_HOSTNAME:-}
Turnstile__Bypass: ${BLOCKLIST_TURNSTILE_BYPASS:-false}
Turnstile__Hostnames__0 is optional: when it is set (for example to blocklist.example.com),
an answer solved on any other hostname is refused.
The console verifies every answer with Cloudflare, so it must reach challenges.cloudflare.com on
port 443. The check fails closed: when Cloudflare cannot be reached, or a key is wrong, nobody can
sign in. A refused check counts against the console's sign-in throttle like any other attempt.
Start the console with Turnstile__Bypass=true (with the lines above,
BLOCKLIST_TURNSTILE_BYPASS=true in .env), or with the start argument
--Turnstile:Bypass=true (in compose, command: ["--Turnstile:Bypass=true"] on the console). The
check is then off although the keys are set: the console warns about it at every start, and the
audit log notes it on every sign-in. Remove it once the cause is fixed.
Settings
Every setting is listed in the reference. The ones worth knowing on day one:
| Variable | Default | What it is |
|---|---|---|
Api__Keys__<name> | - | An accepted API key. At least one is required, and it cannot be revoked through the API |
Blocklist__Entries__<n> | - | The deployment's own entries: full addresses, and @domain for a domain with its subdomains. Shown locked |
Blocklist__ScheduledRefresh | true | false stops every scheduled refresh of the public lists; they are then refreshed only by hand |
RateLimit__Enabled | false | Whether the checks are rate limited at all |
BLOCKLIST_SEED_ADMIN_PASS | generated | The built-in administrator's password. Unset means one is generated on the first start and written to /data/first-admin-password, which only the API's user can read |
BLOCKLIST_SEED_ADMIN_TOTP | - | Optional: the built-in administrator's authenticator secret in base32, at least 26 characters, which makes two-factor required for it; see A required authenticator app |