Repository: TecharoHQ/anubis

Issue:
Feature Request: traefik middleware
Something like #16, but for traefik.

Example project https://github.com/libops/captcha-protect

Discussion:
@snoweuph: this would be awesome.
Im hosting all my services behind traefik.

having it as middelware would make enabling anubis for specific services super easy@zilti: I mean... I don't really see the need for this? I just route all :443 traffic for the subdomains I want guarded to anubis, additionally listen on :3923 and do the actual per-subdomain routing there.@Xe: Can you write up some more about how you do that? That's a very interesting strategy.@Pineapple217: I figured out what @zilti  meant.

To integrate Anubis with Traefik, you need to define a new entry point in Traefik:
```yml
--entrypoints.anubis.address=:3923
```
Then, configure Anubis with the following `docker-compose.yml`
This config takes in all request if there is no rule on port 443, and then sends the request back to Traefik this time on port 3923.
Anubis preserves the `Host` header and Traefik routes again this time going to the final target webservice.
```yml
services:
  anubis:
    image: ghcr.io/techarohq/anubis:latest
    environment:
      BIND: ":8080"
      DIFFICULTY: "5"
      METRICS_BIND: ":9090"
      SERVE_ROBOTS_TXT: "true"
      TARGET: "http://traefik:3923" # Sends results back to Traefik for further routing
    labels:
      - traefik.enable=true
      - traefik.http.routers.anubis.priority=1 # lowest priority
      - traefik.http.routers.anubis.rule=PathPrefix(`/`) # route everything here
      - traefik.http.routers.anubis.entrypoints=websecure
      - traefik.http.services.anubis.loadbalancer.server.port=8080
    networks:
      - proxy

networks:
  proxy:
    external: true
```

Once set up, you can choose how the container is routed in the target application config
Normal Traefik routing (the usual, like Anubis doesn't exits):
```yml
- traefik.http.routers.mywebsite.entrypoints=websecure
```
Route traffic through Anubis:
```yml
- traefik.http.routers.mywebsite.entrypoints=anubis
```

This works fine I think. A dedicated middleware would still be nice but maybe not a priority.
I don't know if there would be a performance/latency difference.
Maybe would be nice if @Xe could add this to the documentation?@thefinn93: Another option might be to add support for using Traefik's [ForwardAuth middleware](https://doc.traefik.io/traefik/middlewares/http/forwardauth/), which uses the same format as [nginx's auth_request module](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html).

EDIT: looks like someone has filed a PR to add this! #368 @blade10101: > I mean... I don't really see the need for this? I just route all :443 traffic for the subdomains I want guarded to anubis, additionally listen on :3923 and do the actual per-subdomain routing there.

I've set this up, but I get just 404 errors...  it dont seem to work that way@imlonghao: > > I mean... I don't really see the need for this? I just route all :443 traffic for the subdomains I want guarded to anubis, additionally listen on :3923 and do the actual per-subdomain routing there.
> 
> I've set this up, but I get just 404 errors... it dont seem to work that way

it works, show your configuration?@snoweuph: > https://github.com/TecharoHQ/anubis/issues/92#issuecomment-2816311671

Show your full config of treafik and anubis.

Alternatively check out the traefik dashboard to See what it is actually reading.
Maybe you got a typo in your labels (asuming youre using Labels) @Pofilo: I wrote a French article if it can help you.
https://www.pofilo.fr/post/2025/04/14-mise-en-place-anubis/

I had troubles because of the TLS configuration which I need to move from gitea to Anubis.@snoweuph: > I wrote a French article if it can help you.
> https://www.pofilo.fr/post/2025/04/14-mise-en-place-anubis/
> 
> I had troubles because of the TLS configuration which I need to move from gitea to Anubis.

ofcourse treafik needs to terminate TLS before anubis.

After treafiks termination, everything should only be raw http @blade10101: > > > I mean... I don't really see the need for this? I just route all :443 traffic for the subdomains I want guarded to anubis, additionally listen on :3923 and do the actual per-subdomain routing there.
> > 
> > 
> > I've set this up, but I get just 404 errors... it dont seem to work that way
> 
> it works, show your configuration?

                                                   docker-compose.yml

services:
  anubis:
    image: ghcr.io/techarohq/anubis:latest
    container_name: anubis
    environment:
      BIND: ":8080"
      TARGET: "http://traefik:3923"
      DIFFICULTY: "4"  # Fast successful response with most devices, 5 is way more slow
      SERVE_ROBOTS_TXT: "true"
    labels:
      - traefik.enable=true
      - traefik.docker.network=npm_default
      - traefik.http.services.anubis.loadbalancer.server.port=8080
      - traefik.http.routers.anubis.priority=1 # lowest priority
      - traefik.http.routers.anubis.rule=PathRegexp(`.*`) # route everything here
      - traefik.http.routers.anubis.entrypoints=https
      - traefik.http.routers.anubis.service=anubis
      - traefik.http.routers.anubis.tls.certresolver=myresolver
    networks:
      - npm_default
    restart: unless-stopped

  traefik:
    image: traefik:3.3
    container_name: "traefik"
    ports:
      - "80:80" # Enterpoint http
      - "443:443" # Entrypoint https
      - "100.117.196.19:8088:8080" # Dashboard
      - "8008:8008"
    volumes:
      - /srv/traefik/traefik.yml:/etc/traefik/traefik.yml
      - /srv/traefik/acme.json:/etc/traefik/acme.json
      - /srv/traefik/config:/etc/traefik/config
      - /srv/traefik/logs:/var/log/traefik
    networks:
      - npm_default
    labels:
      #traefik.http.routers.api.rule: Host(`traefik.darkness.services`)
      #traefik.http.routers.api.entryPoints: https
      #traefik.http.routers.api.service: api@internal
      traefik.enable: true
      #traefik.http.routers.api.middlewares: auth@file
      # Redirect any HTTP to HTTPS
      traefik.http.middlewares.redirect-to-https.redirectscheme.scheme: https
      traefik.http.routers.http.rule: PathPrefix(`/`)
      traefik.http.routers.http.middlewares: redirect-to-https
      traefik.http.routers.http.tls: false
    environment:
      DOCKER_HOST: dockersocket
      CF_DNS_API_TOKEN: **Redacted**
    restart: unless-stopped
    depends_on:
      - dockersocket
    healthcheck:
      test: ["CMD", "curl", "-f", "http://127.0.0.1:8080"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s

  dockersocket:
    container_name: traefik-dockersocket
    image: tecnativa/docker-socket-proxy
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    networks:
      - npm_default
    environment:
      CONTAINERS: 2
      POST: 0
    privileged: true
    restart: always
  whoam:
    image: traefik/whoami:latest
    networks:
      - npm_default
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=npm_default"
      - "traefik.http.routers.whoam.entrypoints=anubis"
      - "traefik.http.services.whoam.loadbalancer.server.port=80"
      - "traefik.http.routers.whoam.service=whoam"
      - "traefik.http.routers.whoam.rule=Host(`whoam.darkness.services`)"
      - "traefik.http.routers.whoam.tls.certresolver=myresolver"
networks:
  npm_default:
    external: true


                                        traefik.yml

api:
  dashboard: true
  insecure: true
  debug: false

entryPoints:
  http:
    address: 80
  https:
    address: :443
    http:
      tls:
        certResolver: myresolver
        domains:
          - main: darkness.services 
            sans:
              - '*.darkness.services' 
      middlewares:
        - securityHeaders@file 
        - rate-limit@file 

  anubis:
    address: :3923
serversTransport:
  insecureSkipVerify: true

providers:
  providersThrottleDuration: 2s
  file:
    directory: /etc/traefik/config
    watch: true
  docker:
    watch: true
    network: npm_default 
    endpoint: "tcp://dockersocket:2375"
    defaultRule: "Host(`{{ index .Labels \"com.docker.compose.service\"}}.darkness.services`)"
    exposedByDefault: false

certificatesResolvers:
  myresolver:
    acme:
      email: **Redacted** 
      storage: /etc/traefik/acme.json
      dnsChallenge: 
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "1.0.0.1:53"
log:
  level: "INFO"
  filePath: "/var/log/traefik/traefik.log"
accessLog:
  filePath: "/var/log/traefik/access.log"
@witten: I'm also getting 404s when trying to use Anubis with Traefik, using pretty much the documented configuration. I've also tried moving the TLS config to the Anubis container's labels, as suggested above. No luck.

IMO the fact that people are having difficulty getting this configuration working is a great argument for having Traefik middleware for Anubis.@witten: Okay, I finally got it working. This is with Traefik v3. I think the problem was actually the `traefik.http.routers.target.service=target` I had included from [the documentation](https://anubis.techaro.lol/docs/admin/environments/traefik). That was yielding errors like:

```
2025-05-22T17:27:49Z ERR error="the service \"target@docker\" does not exist" entryPointName=anubis routerName=target@docker
```

Once I removed that label, the 404s went away and everything seemed to work. Note that I'm also doing the TLS config labels on the Anubis container rather than the target container.

I recommend potentially removing that line from the documentation, so that others don't get hung up on this problem. @blade10101: Looking at your config, this may be your issue as well.@snoweuph: the line is needed though?
I use it with that line, though I use different names for my services personally.

Also after using anubis with traefik for some time now through this workaround,
I happend to notice some problems with getting SSL Certs per Traefik.

Defenitly hoping for a middleware as well
@witten: > the line is needed though?

🤷 

Just reporting what I saw on my machine.@andrewzah: @snoweuph @witten `traefik.http.services.<name>.loadbalancer.server.port=<port>` is not necessarily needed. Traefik uses [port detection](https://doc.traefik.io/traefik/providers/docker/#port-detection) by default.

So *if* the image defines e.g. `EXPOSE 8080`, then you can omit those lines. But Traefik will certainly error if you don't get the service name right.

Anyways I get a 404 when I set `traefik.http.routers.<name>.entrypoints=anubis` regardless- it first redirects first to Anubis, and then after a redirect it's a 404.@dpi0: @andrewzah after an hour of tinkering, this finally worked for me. was also getting 404 after seeing anubis.

i failed to notice the subtle removal of `certresolver: cloudflare` from the protected service. from the [full example config](https://anubis.techaro.lol/docs/admin/environments/traefik/#full-example-config)

//traefik.yml (only the entryPoints section)
```yaml
...
entryPoints:
  web:
    address: :80
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: :443
    http3:
      advertisedPort: 443
  anubis:
    address: :3923
...
```

//anubis/compose.yml
```yaml
services:
  anubis:
    container_name: anubis
    image: ghcr.io/techarohq/anubis:v1.20.0
    environment:
      BIND: :8080
      TARGET: http://traefik:3923
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    read_only: true
    tmpfs:
      - /tmp
    deploy:
      resources:
        limits:
          memory: 128M
          cpus: '0.25'
    labels:
      traefik.enable: true
      traefik.http.routers.anubis.entrypoints: websecure
      traefik.http.routers.anubis.priority: 1
      traefik.http.routers.anubis.rule: PathRegexp(`.*`)
      traefik.http.services.anubis.loadbalancer.server.port: 8080
      traefik.http.routers.anubis.tls.certresolver: cloudflare
    networks:
      - frontend
networks:
  frontend:
    external: true
```

//rimgo/compose.yml (an example service to protect)
```yaml
services:
  rimgo:
    container_name: rimgo
    image: codeberg.org/rimgo/rimgo:1.3.0
    restart: unless-stopped
    user: 65534:65534 # equivalent to `nobody`
    read_only: true
    env_file:
      - .env
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    tmpfs:
      - /tmp
    deploy:
      resources:
        limits:
          memory: 128M
          cpus: '0.25'
    environment:
      PRIVACY_COUNTRY: $PRIVACY_COUNTRY
      PRIVACY_PROVIDER: $PRIVACY_PROVIDER
      PRIVACY_CLOUDFLARE: true
      PRIVACY_NOT_COLLECTED: true
      PRIVACY_IP: true
      PRIVACY_URL: true
      PRIVACY_DEVICE: true
      PRIVACY_DIAGNOSTICS: false
      FIBER_PREFORK: false
      ADDRESS: 0.0.0.0
      PORT: 3000
    labels:
      traefik.enable: true
      traefik.http.routers.rimgo.entrypoints: anubis
      traefik.http.routers.rimgo.rule: "Host(`${FQDN}`)"
      traefik.http.services.rimgo.loadbalancer.server.port: 3000
      traefik.http.routers.rimgo.middlewares: hdrs@file,ratelimit@file
      # CAUTION: DO NOT INCLUDE- traefik.http.routers.rimgo.tls.certresolver: cloudflare HERE, otherwise a 404 is seen fsr
    networks:
      - frontend
networks:
  frontend:
    external: true
```@dpi0: the only problem i haven't been able to solve is to get my custom middlewares working when anubis is set as the entrypoint.

like the security headers set don't apply on the hosted site.

when tested with <https://securityheaders.com>

like this test conducted on a site protected with anubis: <https://securityheaders.com/?q=git.euph.dev&followRedirects=on>

```yaml
services:
  rimgo:
    container_name: rimgo
    image: codeberg.org/rimgo/rimgo:1.3.0
    restart: unless-stopped
    user: 65534:65534 # equivalent to `nobody`
    read_only: true
    env_file:
      - .env
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    tmpfs:
      - /tmp
    deploy:
      resources:
        limits:
          memory: 128M
          cpus: '0.25'
    environment:
      PRIVACY_COUNTRY: $PRIVACY_COUNTRY
      PRIVACY_PROVIDER: $PRIVACY_PROVIDER
      PRIVACY_CLOUDFLARE: true
      PRIVACY_NOT_COLLECTED: true
      PRIVACY_IP: true
      PRIVACY_URL: true
      PRIVACY_DEVICE: true
      PRIVACY_DIAGNOSTICS: false
      FIBER_PREFORK: false
      ADDRESS: 0.0.0.0
      PORT: 3000
    labels:
      traefik.enable: true
      traefik.http.routers.rimgo.entrypoints: anubis
      traefik.http.routers.rimgo.rule: "Host(`${FQDN}`)"
      traefik.http.services.rimgo.loadbalancer.server.port: 3000
      # CAUTION: DO NOT INCLUDE- traefik.http.routers.rimgo.tls.certresolver: cloudflare HERE, otherwise a 404 is seen fsr
      traefik.http.routers.rimgo.middlewares: rimgo-hdr
      traefik.http.middlewares.rimgo-hdr.headers.browserXssFilter: true
      traefik.http.middlewares.rimgo-hdr.headers.contentTypeNosniff: true
      traefik.http.middlewares.rimgo-hdr.headers.frameDeny: true
      traefik.http.middlewares.rimgo-hdr.headers.forceSTSHeader: true
      traefik.http.middlewares.rimgo-hdr.headers.stsIncludeSubdomains: true
      traefik.http.middlewares.rimgo-hdr.headers.stsPreload: true
      traefik.http.middlewares.rimgo-hdr.headers.stsSeconds: 63072000
      traefik.http.middlewares.rimgo-hdr.headers.referrerPolicy: "strict-origin-when-cross-origin"
      traefik.http.middlewares.rimgo-hdr.headers.permissionsPolicy: "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), speaker=(), vibrate=(), fullscreen=(self), sync-xhr=()"

    networks:
      - frontend
networks:
  frontend:
    external: true
```

when `traefik.http.routers.rimgo.entrypoints` is set to `websecure` and `traefik.http.routers.rimgo.tls.certresolver` is set to `cloudflare` only then my security headers apply.@imlonghao: > like the security headers set don't apply on the hosted site.

The request from securityheaders.com was challenged by anubis, you may want to whitelist the request fist.

https://securityheaders.com/.well-known/ipv4.txt@dpi0: > The request from securityheaders.com was challenged by anubis, you may want to whitelist the request fist.
> 
> [securityheaders.com/.well-known/ipv4.txt](https://securityheaders.com/.well-known/ipv4.txt)

can you elaborate? whitelist the request how exactly?@johndevs: I assume this only works with Traefik 3.3 and not the latest 3.x?

For one `PathRegexp` no longer exists (was removed in 3.10)... had to change it to `PathPrefix` which I think does the same job.

Anyway, I'm just getting 404's on the target redirect back to Traefik. Would be nice with a Traefik middleware or updated docs.@BugZappa: +1 for an Anubis middleware for traefik, the current workaround with the ``entrypoint`` pointing to Anubis bypasses other middlewares like Fail2Ban and Geoblock.@v411e: In case this helps somebody:

The documentation suggests to use http to connect to internal services. This did not work for all of my services.

Example: On my ghost instance, I set the url to `http://<mydomain>`, but this made ghost deliver certain images of my blog without tls, which then lead to Firefox blocking these requests. So I needed a way to use `https://<mydomain>` as url parameter on my ghost instance.

So I added `traefik.http.routers.ghost.tls.certresolver=myresolver` on my ghost service and added `TARGET: "https://traefik:3923"` as environment variable for anubis.
Unfortunately, for some reason anubis did not accept the internal traefik TLS certificate and returned a `502`. I suspect traefik used a self-signed certificate here.

Solution: Add the environment variable `TARGET_INSECURE_SKIP_VERIFY: true` to the anubis service. It was introduced in https://github.com/TecharoHQ/anubis/pull/426.

Solution patch:
diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt
index 44642b7..8863a31 100644
--- a/.github/actions/spelling/expect.txt
+++ b/.github/actions/spelling/expect.txt
@@ -103,6 +103,7 @@ Firecrawl
 flagenv
 Fordola
 forgejo
+forwardauth
 fsys
 fullchain
 gaissmai
diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go
index 2d36ae7..c51c86f 100644
--- a/cmd/anubis/main.go
+++ b/cmd/anubis/main.go
@@ -77,6 +77,7 @@ var (
 	extractResources         = flag.String("extract-resources", "", "if set, extract the static resources to the specified folder")
 	webmasterEmail           = flag.String("webmaster-email", "", "if set, displays webmaster's email on the reject page for appeals")
 	versionFlag              = flag.Bool("version", false, "print Anubis version")
+	publicUrl                = flag.String("public-url", "", "the externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for forwardAuth).")
 	xffStripPrivate          = flag.Bool("xff-strip-private", true, "if set, strip private addresses from X-Forwarded-For")
 
 	thothInsecure = flag.Bool("thoth-insecure", false, "if set, connect to Thoth over plain HTTP/2, don't enable this unless support told you to")
@@ -412,6 +413,7 @@ func main() {
 		WebmasterEmail:      *webmasterEmail,
 		OpenGraph:           policy.OpenGraph,
 		CookieSecure:        *cookieSecure,
+		PublicUrl:           *publicUrl,
 	})
 	if err != nil {
 		log.Fatalf("can't construct libanubis.Server: %v", err)
@@ -440,6 +442,7 @@ func main() {
 		"base-prefix", *basePrefix,
 		"cookie-expiration-time", *cookieExpiration,
 		"rule-error-ids", ruleErrorIDs,
+		"public-url", *publicUrl,
 	)
 
 	go func() {
diff --git a/data/common/keep-internet-working.yaml b/data/common/keep-internet-working.yaml
index 68f4756..75d2bda 100644
--- a/data/common/keep-internet-working.yaml
+++ b/data/common/keep-internet-working.yaml
@@ -1,13 +1,13 @@
 # Common "keeping the internet working" routes
 - name: well-known
-  path_regex: ^/.well-known/.*$
+  path_regex: ^/\.well-known/.*$
   action: ALLOW
 - name: favicon
-  path_regex: ^/favicon.(?:ico|png|gif|jpg|jpeg|svg)$
+  path_regex: ^/favicon\.(?:ico|png|gif|jpg|jpeg|svg)$
   action: ALLOW
 - name: robots-txt
-  path_regex: ^/robots.txt$
+  path_regex: ^/robots\.txt$
   action: ALLOW
 - name: sitemap
-  path_regex: ^/sitemap.xml$
+  path_regex: ^/sitemap\.xml$
   action: ALLOW
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index aed9bbe..d6d07f4 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - A standard library HTTP server log message about HTTP pipelining not working has been filtered out of Anubis' logs. There is no action that can be taken about it.
 - The default `favicon` pattern in `data/common/keep-internet-working.yaml` has been updated to permit requests for png/gif/jpg/svg files as well as ico.
 - The `--cookie-prefix` flag has been fixed so that it is fully respected.
+- The default patterns in `data/common/keep-internet-working.yaml` have been updated to appropriately escape the '.' character in the regular expression patterns.
 
 ### Breaking changes
 
@@ -260,6 +261,7 @@ And some cleanups/refactors were added:
 - Bump AI-robots.txt to version 1.37
 - Make progress bar styling more compatible (UXP, etc)
 - Add `--strip-base-prefix` flag/envvar to strip the base prefix from request paths when forwarding to target servers
+- Added support to use Traefik forwardAuth middleware
 - Fix an off-by-one in the default threshold config
 - Add functionality for HS512 JWT algorithm
 - Add support for dynamic cookie domains with the `--cookie-dynamic-domain`/`COOKIE_DYNAMIC_DOMAIN` flag/envvar
diff --git a/docs/docs/admin/environments/traefik.mdx b/docs/docs/admin/environments/traefik.mdx
index 81e1499..197ddc1 100644
--- a/docs/docs/admin/environments/traefik.mdx
+++ b/docs/docs/admin/environments/traefik.mdx
@@ -10,10 +10,6 @@ but it also applies to docker cli options.
 
 :::
 
-Currently, Anubis doesn't have any Traefik middleware,
-so you need to manually route it between Traefik and your target service.
-This routing is done per labels in Traefik.
-
 In this example, we will use 4 Containers:
 
 - `traefik` - the Traefik instance
@@ -21,12 +17,6 @@ In this example, we will use 4 Containers:
 - `target` - our service to protect (`traefik/whoami` in this case)
 - `target2` - a second service that isn't supposed to be protected (`traefik/whoami` in this case)
 
-There are 3 steps we need to follow:
-
-1. Create a new exclusive Traefik endpoint for Anubis
-2. Pass all unspecified requests to Anubis
-3. Let Anubis pass all verified requests back to Traefik on its exclusive endpoint
-
 ## Diagram of Flow
 
 This is a small diagram depicting the flow.
@@ -40,74 +30,16 @@ anubis[Anubis]
 target[Target]
 
 user-->|:443 - Requesting Service|traefik
-traefik-->|:8080 - Passing to Anubis|anubis
-anubis-->|:3923 - Passing back to Traefik|traefik
+traefik-->|:8080 - Check authorization to Anubis|anubis
+anubis-->|redirect if failed|traefik
+user-->|:8080 - make the challenge|traefik
+anubis-->|redirect back to target|traefik
 traefik-->|:80 - Passing to the target|target
 ```
 
-## Create an Exclusive Anubis Endpoint in Traefik
-
-There are 2 ways of registering a new endpoint in Traefik.
-Which one to use depends on how you configured your Traefik so far.
-
-**CLI Options:**
-
-```yml
---entrypoints.anubis.address=:3923
-```
-
-**traefik.yml:**
-
-```yml
-entryPoints:
-  anubis:
-    address: ":3923"
-```
-
-It is important that the specified port isn't actually reachable from the outside,
-but only exposed in the Docker network.
-Exposing the Anubis port on Traefik directly will allow direct unprotected access to all containers behind it.
-
-## Passing all unspecified Web Requests to Anubis
-
-There are cases where you want Traefik to still route some requests without protection, just like before.
-To achieve this, we can register Anubis as the default handler for non-protected requests.
-
-We also don't want users to get SSL Errors during the checking phase,
-thus we also need to let Traefik provide SSL Certs for our endpoint.
-This example expects an TLS cert resolver called `le`.
-
-We also expect there to be an endpoint called `websecure` for HTTPS in this example.
-
-This is an example of the required labels to configure Traefik on the Anubis container:
-
-```yml
-labels:
-  - traefik.enable=true # Enabling Traefik
-  - traefik.docker.network=traefik # Telling Traefik which network to use
-  - traefik.http.routers.anubis.priority=1 # Setting Anubis to the lowest priority, so it only takes the slack
-  - traefik.http.routers.anubis.rule=PathRegexp(`.*`) # Wildcard match every path
-  - traefik.http.routers.anubis.entrypoints=websecure # Listen on HTTPS
-  - traefik.http.services.anubis.loadbalancer.server.port=8080 # Telling Traefik to which port it should route requests
-  - traefik.http.routers.anubis.service=anubis # Telling Traefik to use the above specified port
-  - traefik.http.routers.anubis.tls.certresolver=le # Telling Traefik to resolve a Cert for Anubis
-```
-
-## Passing all Verified Requests Back Correctly to Traefik
-
-To pass verified requests back to Traefik,
-we only need to configure Anubis using its environment variables:
-
-```yml
-environment:
-  - BIND=:8080
-  - TARGET=http://traefik:3923
-```
-
 ## Full Example Config
 
-Now that we know how to pass all requests back and forth, here is the example.
-This example contains 2 services: one that is protected and the other one that is not.
+This example contains 3 services: anubis, one that is protected and the other one that is not.
 
 **compose.yml**
 
@@ -128,6 +60,8 @@ services:
       # Enable Traefik
       - traefik.enable=true
       - traefik.docker.network=traefik
+      # Anubis middleware
+      - traefik.http.middlewares.anubis.forwardauth.address=http://anubis:8080/.within.website/x/cmd/anubis/api/check
       # Redirect any HTTP to HTTPS
       - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
       - traefik.http.routers.web.rule=PathPrefix(`/`)
@@ -140,17 +74,22 @@ services:
     environment:
       # Telling Anubis, where to listen for Traefik
       - BIND=:8080
-      # Telling Anubis to point to Traefik via the Docker network
-      - TARGET=http://traefik:3923
+      # Telling Anubis to do redirect — ensure there is a space after '='
+      - 'TARGET= '
+      # Specifies which domains Anubis is allowed to redirect to.
+      - REDIRECT_DOMAINS=example.com
+      # Should be the full external URL for Anubis (including scheme)
+      - PUBLIC_URL=https://anubis.example.com
+      # Should match your domain for proper cookie scoping
+      - COOKIE_DOMAIN=example.com
     networks:
       - traefik
     labels:
       - traefik.enable=true # Enabling Traefik
       - traefik.docker.network=traefik # Telling Traefik which network to use
-      - traefik.http.routers.anubis.priority=1 # Setting Anubis to the lowest priority, so it only takes the slack
-      - traefik.http.routers.anubis.rule=PathRegexp(`.*`) # wildcard match anything
+      - traefik.http.routers.anubis.rule=Host(`anubis.example.com`) # Only Matching Requests for example.com
       - traefik.http.routers.anubis.entrypoints=websecure # Listen on HTTPS
-      - traefik.http.services.anubis.loadbalancer.server.port=8080 # Telling Traefik to which port it should route requests
+      - traefik.http.services.anubis.loadbalancer.server.port=8080 # Telling Traefik where to receive requests
       - traefik.http.routers.anubis.service=anubis # Telling Traefik to use the above specified port
       - traefik.http.routers.anubis.tls.certresolver=le # Telling Traefik to resolve a Cert for Anubis
 
@@ -163,9 +102,11 @@ services:
       - traefik.enable=true # Enabling Traefik
       - traefik.docker.network=traefik # Telling Traefik which network to use
       - traefik.http.routers.target.rule=Host(`example.com`) # Only Matching Requests for example.com
-      - traefik.http.routers.target.entrypoints=anubis # Listening on the exclusive Anubis Network
+      - traefik.http.routers.target.entrypoints=websecure # Listening on the exclusive Anubis Network
       - traefik.http.services.target.loadbalancer.server.port=80 # Telling Traefik where to receive requests
       - traefik.http.routers.target.service=target # Telling Traefik to use the above specified port
+      - traefik.http.routers.target.tls.certresolver=le # Telling Traefik to resolve a Cert for Anubis
+      - traefik.http.routers.target.middlewares=anubis@docker # Use the Anubis middleware
 
   # Not Protected by Anubis
   target2:
@@ -175,7 +116,7 @@ services:
     labels:
       - traefik.enable=true # Enabling Traefik
       - traefik.docker.network=traefik # Telling Traefik which network to use
-      - traefik.http.routers.target2.rule=Host(`another.com`) # Only Matching Requests for example.com
+      - traefik.http.routers.target2.rule=Host(`another.example.com`) # Only Matching Requests for example.com
       - traefik.http.routers.target2.entrypoints=websecure # Listening on the exclusive Anubis Network
       - traefik.http.services.target2.loadbalancer.server.port=80 # Telling Traefik where to receive requests
       - traefik.http.routers.target2.service=target2 # Telling Traefik to use the above specified port
@@ -198,9 +139,6 @@ entryPoints:
     address: ":80"
   websecure:
     address: ":443"
-  # Anubis
-  anubis:
-    address: ":3923"
 
 certificatesResolvers:
   le:
diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx
index 3a41dda..a6b02b5 100644
--- a/docs/docs/admin/installation.mdx
+++ b/docs/docs/admin/installation.mdx
@@ -77,6 +77,7 @@ Anubis uses these environment variables for configuration:
 | `OG_PASSTHROUGH`               | `false`                 | If set to `true`, Anubis will enable Open Graph tag passthrough. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem.                                                                                                                                                                                                                                                                                                                                  |
 | `OG_CACHE_CONSIDER_HOST`       | `false`                 | If set to `true`, Anubis will consider the host in the Open Graph tag cache key. Prefer using [the policy file](./configuration/open-graph.mdx) to configure the Open Graph subsystem.                                                                                                                                                                                                                                                                                                                  |
 | `POLICY_FNAME`                 | unset                   | The file containing [bot policy configuration](./policies.mdx). See the bot policy documentation for more details. If unset, the default bot policy configuration is used.                                                                                                                                                                                                                                                                                                                              |
+| `PUBLIC_URL`                   | unset                   | The externally accessible URL for this Anubis instance, used for constructing redirect URLs (e.g., for Traefik forwardAuth).                                                                                                                                                                                                 |
 | `REDIRECT_DOMAINS`             | unset                   | If set, restrict the domains that Anubis can redirect to when passing a challenge.<br/><br/>If this is unset, Anubis may redirect to any domain which could cause security issues in the unlikely case that an attacker passes a challenge for your browser and then tricks you into clicking a link to your domain.<br/><br/>Note that if you are hosting Anubis on a non-standard port (`https://example:com:8443`, `http://www.example.net:8080`, etc.), you must also include the port number here. |
 | `SERVE_ROBOTS_TXT`             | `false`                 | If set `true`, Anubis will serve a default `robots.txt` file that disallows all known AI scrapers by name and then additionally disallows every scraper. This is useful if facts and circumstances make it difficult to change the underlying service to serve such a `robots.txt` file.                                                                                                                                                                                                                |
 | `SOCKET_MODE`                  | `0770`                  | _Only used when at least one of the `*_BIND_NETWORK` variables are set to `unix`._ The socket mode (permissions) for Unix domain sockets.                                                                                                                                                                                                                                                                                                                                                               |
diff --git a/internal/headers.go b/internal/headers.go
index 87bca47..8b47866 100644
--- a/internal/headers.go
+++ b/internal/headers.go
@@ -153,7 +153,7 @@ func computeXFFHeader(remoteAddr string, origXFFHeader string, pref XFFComputePr
 	// generally they'd be expected to do these two things on
 	// their own end to find the first non-spoofed IP
 	for i := len(origForwardedList) - 1; i >= 0; i-- {
-		segmentIP, err := netip.ParseAddr(origForwardedList[i])
+		segmentIP, err := netip.ParseAddr(strings.TrimSpace(origForwardedList[i]))
 		if err != nil {
 			// can't assess this element, so the remainder of the chain
 			// can't be trusted. not a fatal error, since anyone can
diff --git a/lib/config.go b/lib/config.go
index 1bfee58..7f5d528 100644
--- a/lib/config.go
+++ b/lib/config.go
@@ -44,6 +44,7 @@ type Options struct {
 	ServeRobotsTXT      bool
 	CookieSecure        bool
 	Logger              *slog.Logger
+	PublicUrl           string
 }
 
 func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
diff --git a/lib/http.go b/lib/http.go
index d548f4d..5418032 100644
--- a/lib/http.go
+++ b/lib/http.go
@@ -1,9 +1,11 @@
 package lib
 
 import (
+	"errors"
 	"fmt"
 	"math/rand"
 	"net/http"
+	"net/url"
 	"regexp"
 	"slices"
 	"strings"
@@ -115,8 +117,17 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
 	localizer := localization.GetLocalizer(r)
 
 	if returnHTTPStatusOnly {
-		w.WriteHeader(http.StatusUnauthorized)
-		w.Write([]byte(localizer.T("authorization_required")))
+		if s.opts.PublicUrl == "" {
+			w.WriteHeader(http.StatusUnauthorized)
+			w.Write([]byte(localizer.T("authorization_required")))
+		} else {
+			redirectURL, err := s.constructRedirectURL(r)
+			if err != nil {
+				s.respondWithStatus(w, r, err.Error(), http.StatusBadRequest)
+				return
+			}
+			http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
+		}
 		return
 	}
 
@@ -196,6 +207,24 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, cr policy.C
 	handler.ServeHTTP(w, r)
 }
 
+func (s *Server) constructRedirectURL(r *http.Request) (string, error) {
+	proto := r.Header.Get("X-Forwarded-Proto")
+	host := r.Header.Get("X-Forwarded-Host")
+	uri := r.Header.Get("X-Forwarded-Uri")
+
+	if proto == "" || host == "" || uri == "" {
+		return "", errors.New("missing required X-Forwarded-* headers")
+	}
+	// Check if host is allowed in RedirectDomains
+	if len(s.opts.RedirectDomains) > 0 && !slices.Contains(s.opts.RedirectDomains, host) {
+		return "", errors.New("redirect domain not allowed")
+	}
+
+	redir := proto + "://" + host + uri
+	escapedURL := url.QueryEscape(redir)
+	return fmt.Sprintf("%s/.within.website/?redir=%s", s.opts.PublicUrl, escapedURL), nil
+}
+
 func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) {
 	localizer := localization.GetLocalizer(r)
 
@@ -255,7 +284,12 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host {
+		hostNotAllowed := len(urlParsed.Host) > 0 &&
+			len(s.opts.RedirectDomains) != 0 &&
+			!slices.Contains(s.opts.RedirectDomains, urlParsed.Host)
+		hostMismatch := r.URL.Host != "" && urlParsed.Host != r.URL.Host
+
+		if hostNotAllowed || hostMismatch {
 			s.respondWithStatus(w, r, localizer.T("redirect_domain_not_allowed"), http.StatusBadRequest)
 			return
 		}



Test patch:
diff --git a/lib/http_test.go b/lib/http_test.go
index 62e2257..4bd353e 100644
--- a/lib/http_test.go
+++ b/lib/http_test.go
@@ -1,10 +1,13 @@
 package lib
 
 import (
+	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"testing"
 
 	"github.com/TecharoHQ/anubis"
+	"github.com/TecharoHQ/anubis/lib/policy"
 )
 
 func TestSetCookie(t *testing.T) {
@@ -129,3 +132,62 @@ func TestClearCookieWithDynamicDomain(t *testing.T) {
 		t.Errorf("wanted cookie max age of -1, got: %d", ckie.MaxAge)
 	}
 }
+
+func TestRenderIndexRedirect(t *testing.T) {
+	s := &Server{
+		opts: Options{
+			PublicUrl: "https://anubis.example.com",
+		},
+	}
+	req := httptest.NewRequest("GET", "/", nil)
+	req.Header.Set("X-Forwarded-Proto", "https")
+	req.Header.Set("X-Forwarded-Host", "example.com")
+	req.Header.Set("X-Forwarded-Uri", "/foo")
+
+	rr := httptest.NewRecorder()
+	s.RenderIndex(rr, req, policy.CheckResult{}, nil, true)
+
+	if rr.Code != http.StatusTemporaryRedirect {
+		t.Errorf("expected status %d, got %d", http.StatusTemporaryRedirect, rr.Code)
+	}
+	location := rr.Header().Get("Location")
+	parsedURL, err := url.Parse(location)
+	if err != nil {
+		t.Fatalf("failed to parse location URL %q: %v", location, err)
+	}
+
+	scheme := "https"
+	if parsedURL.Scheme != scheme {
+		t.Errorf("expected scheme to be %q, got %q", scheme, parsedURL.Scheme)
+	}
+
+	host := "anubis.example.com"
+	if parsedURL.Host != host {
+		t.Errorf("expected url to be %q, got %q", host, parsedURL.Host)
+	}
+
+	redir := parsedURL.Query().Get("redir")
+	expectedRedir := "https://example.com/foo"
+	if redir != expectedRedir {
+		t.Errorf("expected redir param to be %q, got %q", expectedRedir, redir)
+	}
+}
+
+func TestRenderIndexUnauthorized(t *testing.T) {
+	s := &Server{
+		opts: Options{
+			PublicUrl: "",
+		},
+	}
+	req := httptest.NewRequest("GET", "/", nil)
+	rr := httptest.NewRecorder()
+
+	s.RenderIndex(rr, req, policy.CheckResult{}, nil, true)
+
+	if rr.Code != http.StatusUnauthorized {
+		t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
+	}
+	if body := rr.Body.String(); body != "Authorization required" {
+		t.Errorf("expected body %q, got %q", "Authorization required", body)
+	}
+}