Config cleanup for logging, blocking, and a signalr issue

1. The problem I’m having:

I recently made the switch from v2.6.4 to v2.7.6 and as part of that I’ve switched from individual certs to a wildcard, but I’m running into some issues with not duplicating configs with minor modifications. I have everything currently functioning mostly as it did from the previous version, but I’m looking for ways to clean it up/use snippets where it makes sense.

My configs are below, but my pain points are the following:

  • Logging - I would like to use only one or two snippets to split logs per named matcher instead of having to manually include them all in my Caddyfile
  • Blocking IP ranges - I would like to only use one or two snippets to block ranges for each named matcher, would also like to do it on a per path basis when applicable
  • Import orders - Is there a cleaner way to get these all imported?
  • SignalR with the arrs - Am I missing something with websockets for arr apps?

Logging

I want to log each site to its own file without having to add ~5 lines of code per log type per site
Currently I’m only able to get this working with the below Caddyfile lines:

log common-foreveratroll {
  include http.log.access.foreveratroll
  output file /opt/log/caddy/foreveratroll.com
  format transform `{request>headers>X-Forwarded-For>[0]:request>remote_ip} - {request>user_id} [{ts}] "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}"` {
    time_format "02/Jan/2006:15:04:05 -0700"
  }
}
log json-foreveratroll {
  include http.log.access.foreveratroll
  output file /opt/log/caddy/foreveratroll.com.json
  format json
}

I would like to be able to use something like this snippet:

(log-common) {
  log common-{args[0]} {
    include http.log.access.{args[0]}
    output file /opt/log/caddy/{args[0]}
    format transform `{request>headers>X-Forwarded-For>[0]:request>remote_ip} - {request>user_id} [{ts}] "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}"` {
      time_format "02/Jan/2006:15:04:05 -0700"
    }
  }
}

(log-json) {
  log json-{args[0]} {
    include http.log.access.{args[0]}
    output file /opt/log/caddy/{args[0]}.json
    format json
  }
}

That can be called from a site config as:

import log-common "bookstack.foreveratroll.com"

When I have this set up and the import in my main site file this error pops when restarting caddy:

Error: adapting config using caddyfile: parsing caddyfile tokens for 'handle': parsing caddyfile tokens for 'log': include is not allowed in the log directive, at /etc/caddy/caddy.d/snippets.caddy:28 import chain ['/etc/caddy/Caddyfile:40 (import)','/etc/caddy/caddy.d/handlers/broken.foreveratroll.com:4 (import log-common)'], at /etc/caddy/caddy.d/handlers/broken.foreveratroll.com:14 import chain ['/etc/caddy/Caddyfile:41 (import)','/etc/caddy/caddy.d/sites/foreveratroll.com:9 (import broken)']

Is there a correct way to reuse a snippet like this?

Also, the timezones always default to GMT. It’s fine enough when I view these logs through a siem but is there a way to have these just reflect the correct timezone natively?
When using caddy’s internal log rotation mechanism new files are created with 600 perms. Is there an option to

Blocking

Before I switched to a wildcard cert I was able to block external IPs by importing my deny_external snippet. The difference was each site was its own file in /etc/caddy/caddy.d/sites/ but when having them all combined for use with the wildcard I immediately run into errors with duplicate imports. This makes sense, but I’m not sure the correct way to accomplish this aside from my current workaround of using a unique named matcher for each handler file. It’s cumbersome and I’m hoping there’s a better way.

My current workaround - /etc/caddy/caddy.d/handlers/bookstack.foreveratroll.com:

(bookstack) {
    @bookstack host bookstack.foreveratroll.com
    handle @bookstack {
        reverse_proxy nova.foreveratroll.com:6875
        # denies external, remove if exposing outside
        @bookstack_deny not remote_ip private_ranges
        handle @bookstack_deny {
            respond "HTTP/2 403 Forbidden" 403 {
                close
            }
        }
    }
}

Blocking IP ranges to specific paths

Along with the above, I’m trying to also restrict certain paths (vaultwarden’s admin interface for example) but I’m running into issues with the formatting. Is there a way to use the private_ranges shortcut? Or do I have to specify them directly?

Example

(vault) {
	@vault host vault.foreveratroll.com
	handle @vault {
		reverse_proxy praxis.foreveratroll.com:42434

		@vault_forbidden {
			path /.htaccess
			path /data/*
			path /config/*
			path /db_structure
			path /.xml
			path /README
			path /3rdparty/*transport
			path /lib/*
			path /tempaltes/*
			path /occ
			path /console.php
		}
		respond @vault_forbidden 404

		# denies external, remove if exposing outside
		@vault_admin_deny `path('/admin*') && !remote_ip('192.168.0.0/16') && !remote_ip('172.16.0.0/12') && !remote_ip('10.0.0.0/8') && !remote_ip('127.0.0.1/8')` 
		respond @vault_admin_deny "HTTP/2 403 Forbidden" 403 {
			close
		}
	}
}

Importing

Back to /etc/caddy/caddy.d/sites/foreveratroll.com:
The full site file is annoyingly long and I’ve found with the current way I’m denying external IPs I need to put all the public ones at the top to import before any that have a block external rule in them. I’ve already forgotten this a few times and spent an annoying amount of time troubleshooting a self-made issue. Is there a better way to define multiple different sites without dumping them all into one megafile?

SignalR

All of my arr applications throw errors with signalr with a link to their docs but only have a Caddy v1 example. Is there something special I need to do for websockets? I have never gotten this to work with Caddy even though websockets seem to function everywhere else.

/etc/caddy/caddy.d/handlers/radarr.foreveratroll.com:

(radarr) {
	@radarr host radarr.foreveratroll.com movies.foreveratroll.com
	handle @radarr {
		reverse_proxy eris.foreveratroll.com:7878 {
			header_up -Accept-Encoding
		}
		filter {
			content_type text/html.*
			search_pattern </head>
			replacement "<link rel='stylesheet' type='text/css' href='https://theme-park.dev/css/base/radarr/organizr.css'></head>"
		}
		# denies external, remove if exposing outside
		@radarr_deny not remote_ip private_ranges
		handle @radarr_deny {
			respond "HTTP/2 403 Forbidden" 403 {
				close
			}
		}
	}
}

When I turn debug logging on it throws no errors in the logs

2024-02-07 16:25:27.0|Debug|Radarr.Http.Authentication.ApiKeyAuthenticationHandler|AuthenticationScheme: SignalR was successfully authenticated.

2. Error messages and/or full log output:

All of the errors are similarly malformed Caddyfile configs based on how I’m trying to utilize snippets.

3. Caddy version:

Built with this Dockerfile:

FROM caddy:2.7.6-builder-alpine AS builder

RUN apk add --no-cache tzdata

RUN xcaddy build v2.7.6 \
    --with github.com/caddy-dns/cloudflare@latest \
    --with github.com/caddyserver/transform-encoder \
    --with github.com/sjtug/caddy2-filter

FROM caddy:2.7.6

COPY --from=builder /usr/bin/caddy /usr/bin/caddy
$ docker exec caddy caddy version
v2.7.6 h1:w0NymbG2m9PcvKWsrXO6EEkY9Ru4FJK8uQbYcev1p3A=

4. How I installed and ran Caddy:

Custom docker image

a. System environment:

Linux proxy.foreveratroll.com 5.10.0-27-cloud-amd64 #1 SMP Debian 5.10.205-2 (2023-12-31) x86_64 GNU/Linux
Docker version 24.0.7, build afdd53b

b. Command:

N/a

c. Service/unit/compose file:

docker-compose.yml:

---
networks:
  frontend:
    name: frontend
    external: true

version: "3.7"
services:
  caddy:
    image: cr.foreveratroll.com/caddy:v2.7.6-fl2
    container_name: caddy
    hostname: caddy
    restart: unless-stopped
    environment:
      CF_API_TOKEN: ${CF_API_TOKEN}
      TZ: ${TZ}
    labels:
      com.foreveratroll.backup.enable: "false"
      com.foreveratroll.service.name: "caddy"
      diun.enable: "false"
    networks:
      - frontend
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    security_opt:
      - no-new-privileges:true
    volumes:
      - /opt/docker/caddy/Caddyfile:/etc/caddy/Caddyfile
      - /opt/docker/caddy/caddy.d:/etc/caddy/caddy.d
      - /opt/docker/caddy/data/site:/srv
      - /opt/docker/caddy/data/caddy-data:/data
      - /opt/docker/caddy/data/config:/config
      - /opt/log/caddy:/opt/log/caddy

.env:

TZ=America/Phoenix
CF_API_TOKEN=

d. My complete Caddy config:

Caddyfile:

{
	debug
	# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory # testing api
	http_port 80
	https_port 443
	order filter after encode # needed for themepark
	order log last # logging last

	log common-foreveratroll {
		include http.log.access.foreveratroll
		output file /opt/log/caddy/foreveratroll.com
		format transform `{request>headers>X-Forwarded-For>[0]:request>remote_ip} - {request>user_id} [{ts}] "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}"` {
			time_format "02/Jan/2006:15:04:05 -0700"
		}
	}
	log json-foreveratroll {
		include http.log.access.foreveratroll
		output file /opt/log/caddy/foreveratroll.com.json
		format json
	}

	servers :443 {
		name https:
		listener_wrappers {
			http_redirect
			tls
		}
		# https://www.cloudflare.com/ips/
		trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32
		client_ip_headers CF-Connecting-IP
		# client_ip_headers X-Forwarded-For
	}

	servers :80 {
		name http
		protocols h1 h2c
	}
}

import /etc/caddy/caddy.d/snippets.caddy
import /etc/caddy/caddy.d/handlers/*
import /etc/caddy/caddy.d/sites/*

snippets.caddy:

(deny_external) {
    @denied not remote_ip private_ranges
    handle @denied {
        respond "HTTP/2 403 Forbidden deny_external" 403 {
            close
        }
    }
}

(init) {
    encode zstd gzip
    header {
        -Server
        -x-powered-by
        x-proxy-version v2.7.6-fl2
        Strict-Transport-Security "max-age=63072000;includeSubDomains; "
    }
}

(tls) {
    tls {
        dns cloudflare "{env.CF_API_TOKEN}"
    }
}

Externally available site - /etc/caddy/caddy.d/handlers/vault.foreveratroll.com:

(vault) {
	@vault host vault.foreveratroll.com
	handle @vault {
		reverse_proxy praxis.foreveratroll.com:42434

		@vault_forbidden {
			path /.htaccess
			path /data/*
			path /config/*
			path /db_structure
			path /.xml
			path /README
			path /3rdparty/*transport
			path /lib/*
			path /tempaltes/*
			path /occ
			path /console.php
		}
		respond @vault_forbidden 404

		# denies external, remove if exposing outside
		@vault_admin_deny `path('/admin*') && !remote_ip('192.168.0.0/16') && !remote_ip('172.16.0.0/12') && !remote_ip('10.0.0.0/8') && !remote_ip('127.0.0.1/8')` 
		respond @vault_admin_deny "HTTP/2 403 Forbidden" 403 {
			close
		}
	}
}

Internal only site - /etc/caddy/caddy.d/handlers/bookstack.foreveratroll.com:

(bookstack) {
    @bookstack host bookstack.foreveratroll.com
    handle @bookstack {
        reverse_proxy nova.foreveratroll.com:6875
        # denies external, remove if exposing outside
        @bookstack_deny not remote_ip private_ranges
        handle @bookstack_deny {
            respond "HTTP/2 403 Forbidden" 403 {
                close
            }
        }
    }
}

The site file that pulls these all together /etc/caddy/caddy.d/sites/foreveratroll.com:

*.foreveratroll.com, foreveratroll.com {
	import init
	import tls

	import audiobookshelf
	import nextcloud
	import vault

	# import deny_external
	import answer
	import bazarr
	import bookstack
	import calibre
	import deluge

	log foreveratroll
	handle {
		respond "HTTP/2 404 Not Found" 404 {
			close
		}
	}
}

5. Links to relevant resources:

Okay, holy moly this is a monster of a post :joy: it’ll take me a bit of time to get through it all.

We’ve recently added a hostnames option to the log directive. There’s an example here log (Caddyfile directive) — Caddy Documentation

So you could use a snippet using the hostname as an arg that you put just before the host matcher.

Replace this with simply request>client_ip. This is also relatively new, and it uses the trusted_proxies from global options to correctly determine the client IP which is logged.

What’s going on here is that the log directive and the log global option share parsing code, but include and exclude are only allowed in the global option.

Anyway, the solution for wildcards + logging is :point_up: up there

There’s a time_local option, see log (Caddyfile directive) — Caddy Documentation

Unfortunately, no :frowning: the log rolling library we use GitHub - natefinch/lumberjack: lumberjack is a log rolling package for Go has fallen out of maintenance and that’s still an open feature request for file permissions. We’ve thought of forking it, but we’re already stretched thin enough as it is with Caddy things, so we haven’t gotten around to that yet. We’re hoping someone can step up and help us out with that!

You can move your named matcher to “the top of your site block” so it’s defined for all host handles, then reuse the matcher. You can reuse a named matcher, but you can’t redefine it. I’d call it @deny_public since you’re denying all public IPs, basically.

But you can simplify it to this; you can apply matchers to regular directives too:

	@deny_public not remote_ip private_ranges
	error @deny_public 403

With error you can define a handle_errors to specifically handle 403 errors and render an HTML page or whatever.

Or if you want to not even show an error and drop the request as fast as possible, use abort instead:

	@bookstack_deny not remote_ip private_ranges
	abort @bookstack_deny

Keep in mind, both of these have higher directive order than reverse_proxy, so that’s why they will run first when in the same handle.

You can simplify this by merging it all into one remote_ip matcher. You can pass more than one CIDR to it, comma separated. Reminder, a boolean expression with !a && !b && !c is the same as !(a || b || c).

But also yes you can use private_ranges, it is supported in the expression matcher as well.

You might want the client_ip matcher though? It uses the IP address parsed from trusted_proxies. It works the same as remote_ip, except it prefers the “real IP” instead.

If you want, you can list more than one path on the same line as args to path. Doesn’t have to all be on new lines. Not a big deal, just a suggestion if you prefer it.

I would suggest error @vault_forbidden 404 instead so you can use handle_errors to re-handle it if you want, and show an HTML error page or w/e.

I’m not sure I follow what you mean here. Hopefully with the above suggestions this becomes a non-issue? I dunno. Let’s circle back to this one afterwards, it’s easier to deduplicate once everything is configured “correctly”.

No, websockets works out of the box with Caddy v2, with no options necessary.

In what way is it not working? I don’t really understand the problem. What’s the error message you see, etc?

I think you might mean to have tzdata in the final image, not the builder image? You’d need to move this under the 2nd FROM.

Remove these, it’s useless – re-stating the defaults has no benefit.

This doesn’t make sense, log is not a handler directive and therefore has no “order” to begin with. You can remove this.

This looks weird, I’d change this to https instead of https: :thinking:

You can use this plugin to avoid needing to hard-code the IPs GitHub - WeidiDeng/caddy-cloudflare-ip

Removing Server has no benefit; it doesn’t reveal any sensitive information.

In fact, adding a X-Proxy-Version header does give bad actors something to work with; if you fall behind on updates, then they would be able to look at known vulnerabilities to try to make bad things happen.

1 Like

I’d been debating all day if a giant post was worse than individual one offs, you’re my hero for rooting everything out of it in a meaningful way :heart:

Just reading through this you’ve given me a lot of clarity and stuff to work on. I’m going to chew on it and implement things asap and will be back with any follow ups.

Thank you so much for your time and suggestions, I super appreciate it.

1 Like

Credit to you for laying it out in a good way, it made it easier to run though. :slight_smile:

Alright, so I’ve got the wildcard subdomains going into their own files with the following snippet(s):

(log-common) {
	log {
		hostnames {args[0]}
		output file /opt/log/caddy/{args[0]}
		format transform `{request>client_ip} - {request>user_id} [{ts}] "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}"` {
			time_format "02/Jan/2006:15:04:05 -0700"
			time_local
		}
	}
}

(log-json) {
	log {
		hostnames {args[0]}
		output file /opt/log/caddy/{args[0]}.json
		format json {
			time_local
		}
	}
}

being called like this in one of my handler files:

(cockpit) {
	import log-json cockpit.foreveratroll.com
	import log-common cockpit.foreveratroll.com
	@cockpit host cockpit.foreveratroll.com
	handle @cockpit {
		reverse_proxy metro.foreveratroll.com:9090 {
			transport http {
				tls_insecure_skip_verify
			}
		}
	}
}

As expected, this only uses the last defined logger before the handler, but they do each work when the other is commented out. Is there a way to split these into two separate files as described here? My hang up is this requires using include which is only available in the global option, but the wildcards are defined in the log directive.

I’m an old head and prefer my common log format when troubleshooting on box but I’m going to work on getting things into Kibana where I’d prefer the full json format, so it may not be the end of the world if this isn’t possible.

When I add another site the new one ip doesn’t seem to log at all. Is this related to using templates or something?

ip.foreveratroll.com:

(ip) {
	# import log-json ip.foreveratroll.com
	import log-common ip.foreveratroll.com
	@ip host ip.foreveratroll.com
	handle @ip {
		header Content-Type text/plain
		templates
		respond "{{.ClientIP}}" 200
	}
}

Both of these get imported back to back and serve the sites properly, it’s just logging for the ip one that isn’t working. I added a 3rd site imported after ip which works as well so I’m fairly confident it has to do with the template, I just don’t know why.

The timezone issue was fixed by moving the tzdata block after my second FROM in my Dockerfile and including time_local

I think you can give each of them a unique name, then they would both work, without include.

The name is given as the first argument next to log. So like log {args[0]}-json or something like that.

You can use caddy adapt -p to see what the JSON config looks like, it should give you a better idea of how things play together after being adapted. Basically you’re looking for logger_names which is the mapping of hostnames to a logger name; and the loggers are defined globally (essentially, loggers in site blocks are merged with loggers in global options, but they are semantically different in the Caddyfile since site block ones only affect access logs).

I don’t understand. I think some context is missing here. Are you sure you reloaded your config after making that change?

You can look at the adapted JSON to see if anything is amiss.

You don’t need templates for this, you can just do respond {client_ip} instead.

Switching from templates to just respond {client_ip} fixed that particular issue. This is now the snippet I’m trying to use for the split logging:

(log-both) {
	log {args[0]}-common {
		hostnames {args[0]}
		output file /opt/log/caddy/{args[0]}
		format transform `{request>client_ip} - {request>user_id} [{ts}] "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}"` {
			time_format "02/Jan/2006:15:04:05 -0700"
			time_local
		}
	}

	log {args[0]}-json {
		hostnames {args[0]}
		output file /opt/log/caddy/{args[0]}.json
		format json {
			time_local
		}
	}
}

It breaks all logging again, so I’m sure I’ve messed something up. I couldn’t get caddy adapt -p to work in the container either, but I’m sure that’s a me issue.

Am I on the right path for the split logging or did I misunderstand? I’ll give the adapt another shot when I have some time.

Thanks so much for your help so far.

Make sure the working directory is set properly, or specify the path to the config file. So add -w /etc/caddy to your Docker command, or add -c /etc/caddy/Caddyfile to the adapt command.

I’m not sure :thinking: I’d have to adapt it myself and look at it – or you can share the adapted JSON to save me some effort :joy:

I needed that -c flag :sweat_smile:

I went ahead and cleared out /opt/log/caddy and see no new file created with the current config.

{
	"logging": {
		"logs": {
			"default": {
				"exclude": [
					"http.log.access.ip.foreveratroll.com-common",
					"http.log.access.ip.foreveratroll.com-json",
					"http.log.access.log0",
					"http.log.access.log1"
				]
			},
			"ip.foreveratroll.com-common": {
				"writer": {
					"filename": "/opt/log/caddy/ip.foreveratroll.com",
					"output": "file"
				},
				"encoder": {
					"format": "transform",
					"template": "{request\u003eclient_ip} - {request\u003euser_id} [{ts}] \"{request\u003emethod} {request\u003euri} {request\u003eproto}\" {status} {size} \"{request\u003eheaders\u003eReferer\u003e[0]}\" \"{request\u003eheaders\u003eUser-Agent\u003e[0]}\"",
					"time_format": "02/Jan/2006:15:04:05 -0700",
					"time_local": true
				},
				"include": [
					"http.log.access.ip.foreveratroll.com-common"
				]
			},
			"ip.foreveratroll.com-json": {
				"writer": {
					"filename": "/opt/log/caddy/ip.foreveratroll.com.json",
					"output": "file"
				},
				"encoder": {
					"format": "json",
					"time_local": true
				},
				"include": [
					"http.log.access.ip.foreveratroll.com-json"
				]
			},
			"log0": {
				"writer": {
					"filename": "/opt/log/caddy/cockpit.foreveratroll.com",
					"output": "file"
				},
				"encoder": {
					"format": "transform",
					"template": "{request\u003eclient_ip} - {request\u003euser_id} [{ts}] \"{request\u003emethod} {request\u003euri} {request\u003eproto}\" {status} {size} \"{request\u003eheaders\u003eReferer\u003e[0]}\" \"{request\u003eheaders\u003eUser-Agent\u003e[0]}\"",
					"time_format": "02/Jan/2006:15:04:05 -0700",
					"time_local": true
				},
				"include": [
					"http.log.access.log0"
				]
			},
			"log1": {
				"writer": {
					"filename": "/opt/log/caddy/ghost.foreveratroll.com.json",
					"output": "file"
				},
				"encoder": {
					"format": "json",
					"time_local": true
				},
				"include": [
					"http.log.access.log1"
				]
			}
		}
	},
	"apps": {
		"http": {
			"servers": {
				"https": {
					"listen": [
						":443"
					],
					"listener_wrappers": [
						{
							"wrapper": "http_redirect"
						},
						{
							"wrapper": "tls"
						}
					],
					"routes": [
						{
							"match": [
								{
									"host": [
										"*.foreveratroll.com",
										"foreveratroll.com"
									]
								}
							],
							"handle": [
								{
									"handler": "subroute",
									"routes": [
										{
											"handle": [
												{
													"handler": "headers",
													"response": {
														"deferred": true,
														"delete": [
															"Server",
															"x-powered-by"
														],
														"set": {
															"Strict-Transport-Security": [
																"max-age=63072000;includeSubDomains; "
															],
															"X-Proxy-Version": [
																"v2.7.6-fl5"
															]
														}
													}
												},
												{
													"encodings": {
														"gzip": {},
														"zstd": {}
													},
													"handler": "encode",
													"prefer": [
														"zstd",
														"gzip"
													]
												}
											]
										},
										{
											"group": "group6",
											"handle": [
												{
													"handler": "subroute",
													"routes": [
														{
															"handle": [
																{
																	"handler": "subroute",
																	"routes": [
																		{
																			"handle": [
																				{
																					"handler": "error",
																					"status_code": 403
																				}
																			],
																			"match": [
																				{
																					"not": [
																						{
																							"remote_ip": {
																								"ranges": [
																									"10.13.37.0/24"
																								]
																							}
																						}
																					]
																				}
																			]
																		}
																	]
																}
															],
															"match": [
																{
																	"not": [
																		{
																			"remote_ip": {
																				"ranges": [
																					"10.13.37.0/24"
																				]
																			}
																		}
																	]
																}
															]
														},
														{
															"handle": [
																{
																	"handler": "reverse_proxy",
																	"transport": {
																		"protocol": "http",
																		"tls": {
																			"insecure_skip_verify": true
																		}
																	},
																	"upstreams": [
																		{
																			"dial": "metro.foreveratroll.com:9090"
																		}
																	]
																}
															]
														}
													]
												}
											],
											"match": [
												{
													"host": [
														"cockpit.foreveratroll.com"
													]
												}
											]
										},
										{
											"group": "group6",
											"handle": [
												{
													"handler": "subroute",
													"routes": [
														{
															"handle": [
																{
																	"handler": "headers",
																	"response": {
																		"set": {
																			"Content-Type": [
																				"text/plain"
																			]
																		}
																	}
																},
																{
																	"body": "{http.vars.client_ip}",
																	"handler": "static_response",
																	"status_code": 200
																}
															]
														}
													]
												}
											],
											"match": [
												{
													"host": [
														"ip.foreveratroll.com"
													]
												}
											]
										},
										{
											"group": "group6",
											"handle": [
												{
													"handler": "subroute",
													"routes": [
														{
															"handle": [
																{
																	"handler": "headers",
																	"response": {
																		"deferred": true,
																		"delete": [
																			"Server",
																			"server",
																			"x-powered-by"
																		],
																		"set": {
																			"X-Proxy-Version": [
																				"v2.7.6-fl5"
																			]
																		}
																	}
																}
															]
														},
														{
															"handle": [
																{
																	"handler": "subroute",
																	"routes": [
																		{
																			"handle": [
																				{
																					"handler": "error",
																					"status_code": 403
																				}
																			],
																			"match": [
																				{
																					"not": [
																						{
																							"remote_ip": {
																								"ranges": [
																									"10.13.37.0/24"
																								]
																							}
																						}
																					]
																				}
																			]
																		}
																	]
																}
															],
															"match": [
																{
																	"not": [
																		{
																			"remote_ip": {
																				"ranges": [
																					"10.13.37.0/24"
																				]
																			}
																		}
																	]
																}
															]
														},
														{
															"handle": [
																{
																	"handler": "reverse_proxy",
																	"upstreams": [
																		{
																			"dial": "10.13.37.38:40152"
																		}
																	]
																}
															]
														}
													]
												}
											],
											"match": [
												{
													"host": [
														"ghost.foreveratroll.com"
													]
												}
											]
										},
										{
											"group": "group6",
											"handle": [
												{
													"handler": "subroute",
													"routes": [
														{
															"handle": [
																{
																	"body": "HTTP/2 404 Not Found",
																	"close": true,
																	"handler": "static_response",
																	"status_code": 404
																}
															]
														}
													]
												}
											]
										}
									]
								}
							],
							"terminal": true
						}
					],
					"trusted_proxies": {
						"ranges": [
							"173.245.48.0/20",
							"103.21.244.0/22",
							"103.22.200.0/22",
							"103.31.4.0/22",
							"141.101.64.0/18",
							"108.162.192.0/18",
							"190.93.240.0/20",
							"188.114.96.0/20",
							"197.234.240.0/22",
							"198.41.128.0/17",
							"162.158.0.0/15",
							"104.16.0.0/13",
							"104.24.0.0/14",
							"172.64.0.0/13",
							"131.0.72.0/22",
							"2400:cb00::/32",
							"2606:4700::/32",
							"2803:f800::/32",
							"2405:b500::/32",
							"2405:8100::/32",
							"2a06:98c0::/29",
							"2c0f:f248::/32"
						],
						"source": "static"
					},
					"logs": {
						"logger_names": {
							"cockpit.foreveratroll.com": "log0",
							"ghost.foreveratroll.com": "log1",
							"ip.foreveratroll.com": "ip.foreveratroll.com-json"
						}
					}
				}
			}
		},
		"tls": {
			"automation": {
				"policies": [
					{
						"subjects": [
							"*.foreveratroll.com",
							"foreveratroll.com"
						],
						"issuers": [
							{
								"challenges": {
									"dns": {
										"provider": {
											"api_token": "{env.CF_API_TOKEN}",
											"name": "cloudflare"
										}
									}
								},
								"module": "acme"
							},
							{
								"challenges": {
									"dns": {
										"provider": {
											"api_token": "{env.CF_API_TOKEN}",
											"name": "cloudflare"
										}
									}
								},
								"module": "zerossl"
							}
						]
					}
				]
			}
		}
	}
}
"logs": {
	"logger_names": {
		"cockpit.foreveratroll.com": "log0",
		"ghost.foreveratroll.com": "log1",
		"ip.foreveratroll.com": "ip.foreveratroll.com-json"
	}
}

Oh right :grimacing: logger names only allows one logger per hostname.

Hmm…

Oh right – the intent of allowing logger name overrides in site blocks was to let you define a logger in global options with that same name for reuse.

It was implemented in httpcaddyfile: Allow `hostnames` & logger name overrides for log directive by francislavoie · Pull Request #5643 · caddyserver/caddy · GitHub, there’s an explanation in there.

I didn’t think anyone would want to have two loggers per domain though. So I guess that’s not supported as-is right now.

It would be possible if you actually defined your loggers in global options explicitly for each domain name with a specific name, and then point to that logger name in your site block… but I know that’s not what you want because there’s no easy way to do that with your config structure with import etc.

I had to scratch the itch.

I’m exploring if we can replace logger_names with logger_mapping, i.e. a new config which takes an array of logger names per host, instead of only a single logger per host.

I rebuilt my docker image with this Dockerfile:

FROM caddy:2.7.6-builder-alpine AS builder

RUN xcaddy build v2.7.6 \
    --with github.com/caddyserver/caddy/v2=github.com/caddyserver/caddy/v2@multi-loggers \
    --with github.com/caddy-dns/cloudflare@latest \
    --with github.com/caddyserver/transform-encoder \
    --with github.com/sjtug/caddy2-filter

FROM caddy:2.7.6

RUN apk add --no-cache tzdata

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

And my existing log-both snippet works as expected, logging to both files simultaneously. You’ve solved this niche corner case quite nicely. Hopefully it gets into v2.8!

The snippet for posterity:

(log-both) {
	log {args[0]}-common {
		hostnames {args[0]}
		output file /opt/log/caddy/{args[0]}
		format transform `{request>client_ip} - {request>user_id} [{ts}] "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}"` {
			time_format "02/Jan/2006:15:04:05 -0700"
			time_local
		}
	}

	log {args[0]}-json {
		hostnames {args[0]}
		output file /opt/log/caddy/{args[0]}.json
		format json {
			time_local
		}
	}
}

Which I’ve added to my handler snippet block like

import log-both ip.foreveratroll.com

I’m gonna go back to implementing the other suggestions from your initial reply and come back with follow ups.

Nice, thanks for trying it!

I’m not 100% sure it’ll land just like that, so keep an eye on that PR in case things change.