Securing web apps with Caddy and Authelia in Docker Compose: an opinionated, practical, and minimal production-ready login portal guide


This post is intended to provide a practical guide to achieving a production-ready forward-authentication solution that can provide a polished unified login experience with MFA to arbitrary Caddy servers, in turn protecting multiple separately-hosted web apps and services.

Forward authentication

Ever since the release of Caddy version 2.5.1 (see: Release v2.5.1 · caddyserver/caddy · GitHub), there’s a new wrapper directive for the Caddyfile: forward_auth, which configures a subrequest to an authenticating backend. Authelia is touted as the exemplar for this purpose (see: GitHub - authelia/authelia: The Single Sign-On Multi-Factor portal for web apps).

This separates the authentication & authorization handling to that other service, rather than handling that within the Caddy server itself.

Why not security in Caddy itself?

Form-based login, JWT authentication and authorization, with multi-factor support - this all obviously a smashing idea, but why not do this directly in Caddy? We have the excellent option in, which advertises all of these features.

Tight integration to the server itself may reduce footguns (of which there were indeed some encountered in the course of formulating this guide!), and may reduce complexity (of which there is indeed an added amount by virtue of configuring, running, and maintaining multiple separate services).

However, there are also some logical benefits to externalizing the authentication and authorization from the web server itself; but the biggest benefit in my view, by a long shot, is the incredible advantage of configuring security ONCE in a centralised manner for MULTIPLE Caddy servers.

As someone who hosts multiple services on a few different cloud platforms as well as maintaining my own home lab with a significant number of services by itself, logging in to one set of services and then navigating to another set of services and having to log in there instead is less than ideal; more so as it is not trivial to configure TOTP across many services, and juggling TOTPs becomes painful after a while. Being able to login on one set of services with a TOTP and then browse to a different set of services seamlessly - that’s a big win for me!

The stack

We’re going to be relying on the following moving parts that will comprise the full auth service:

  • Caddy, as a HTTPS reverse proxy; in front of
  • Authelia, obviously; using
  • Redis, as the recommended memory store for login sessions; and
  • Postgres, as a database for TOTP handles, auth logs, etc; all handled by
  • Docker (Compose), to containerise all of the above

Then, on other projects we need to secure, we’ll be using Caddy again with forward auth.

0) Project directory

Pick somewhere to store the Compose file and various configuration/data files. This guide assumes you have a directory dedicated to this project and its stack (such as in my case /opt/docker/authelia), in which you will be bind-mounting subdirectories to store config for each service.

1) Secrets

As part of the process, we will be generating a number of secrets; however, one secret file you will naturally need to supply yourself: your SMTP password for Authelia to send emails.

Every other secret we need we’ll be generating fresh, and they will all be stored and loaded from a series of files; this is the recommendation of the Authelia developers, as it keeps these secrets out of the container’s environment. Here are the secrets we want:

(Some more information about secrets management in Authelia can be found here.)

All of the above are recommended to be 64+ character long random alphanumeric strings, and Authelia’s documentation advises how these should be generated. For brevity’s sake, here are the commands you will want to run:

mkdir -p config/secrets
tr -cd '[:alnum:]' < /dev/urandom | fold -w 64 | head -n 1 | tr -d '\n' > config/secrets/JWT_SECRET
tr -cd '[:alnum:]' < /dev/urandom | fold -w 64 | head -n 1 | tr -d '\n' > config/secrets/SESSION_SECRET
tr -cd '[:alnum:]' < /dev/urandom | fold -w 64 | head -n 1 | tr -d '\n' > config/secrets/STORAGE_PASSWORD
tr -cd '[:alnum:]' < /dev/urandom | fold -w 64 | head -n 1 | tr -d '\n' > config/secrets/STORAGE_ENCRYPTION_KEY
tr -cd '[:alnum:]' < /dev/urandom | fold -w 64 | head -n 1 | tr -d '\n' > config/secrets/REDIS_PASSWORD

Lastly, you will want to nano config/secrets/SMTP_PASSWORD and paste the SMTP password to the account you intend to use to send emails.

2) Authelia configuration

So, you just pull and start working your way through one thousand, four hundred and thirty-eight (1438!) lines of co-

Nah, just kidding. I strongly recommend using that link as a reference when working on your config along with Prologue - Configuration - Authelia, but unless you want to spend a long time sorting things out, I’d recommend using a minimal base configuration:

# Miscellaneous
theme: auto
default_redirection_url: # Change me!

# First Factor
    path: /config/users_database.yml

# Second Factor
  issuer: # Change me!

# Security
  default_policy: two_factor

# Session
  domain: # Change me!

    host: redis
    port: 6379

# Storage
    host: database
    database: authelia
    username: authelia

# SMTP Notifier
    host:                     # Change me!
    port: 465                                  # Change me!
    username:                  # Change me!
    sender: "Authelia <>"  # Change me!

Save the above to config/configuration.yml (note: NOT config/config.yml) - then go through and change all the Change me! items, which should mostly be self-explanatory. Each section has its link to the documentation commented; refer to the docs for more detail and also to see what configuration lines have been omitted in favor of their defaults.

3) Authelia Users Database

Now we need to set up who can actually log in with a simple yaml file as our user database. The format of this file is described in Passwords - Reference - Authelia, but here is another minimal base configuration to start from:

# User file database
# Generate passwords
# docker run --rm -it authelia/authelia:latest authelia crypto hash generate argon2
        password: [hashed password]
        displayname: "My User"

Change myusername to your username of choice and set the display name and email address.

To generate your password, we’re going to use Authelia itself to handle the crypto hash generation. This is done rather handily directly through Docker; simply run:

docker run --rm -it authelia/authelia:latest authelia crypto hash generate argon2

And type in your password of choice into the secure prompt. After confirming, it will spit out a Digest: [hashed password] string; copy the [hashed password] (it should start with $argon2id$v=19$m=65536,t=3,p=4$ or similar) into the password stanza of the users database file.

Save this file to config/users_database.yml.

4) Review files and secure them

You should now have the following files in place:

$ sudo tree /opt/docker/authelia/config
├── configuration.yml
├── secrets
│   ├── JWT_SECRET
└── users_database.yml

1 directory, 8 files

Go over your configuration.yml and users_database.yml again now to double check everything looks correct. Then, make sure this entire config directory is secured:

sudo chown -R root:root /opt/docker/authelia/config
sudo chmod -R 600 /opt/docker/authelia/config

5) Caddy configuration

First, add a folder that’s going to contain your Caddy configuration and TLS assets:

mkdir /opt/docker/authelia/caddy

Then lets add a Caddyfile:

nano /opt/docker/authelia/caddy/Caddyfile

And we’re going to paste in the following configuration: {
  reverse_proxy app:9091

Make sure to update to the domain name you’ll be serving Authelia on. This domain name must point at your server and be publicly reachable.

6) Docker Compose file

Add your Compose file now:

nano /opt/docker/authelia/docker-compose.yml

We’re going to start with the following configuration:

name: "authelia"

    image: caddy:latest
    #restart: unless-stopped
      - "80:80"
      - "443:443"
      - ./caddy/data:/data
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile

    image: authelia/authelia:latest
    restart: unless-stopped
      - database
      - redis
      - ./config:/config

    image: postgres:15
    restart: unless-stopped
      - ./postgres:/var/lib/postgresql/data
      POSTGRES_USER: "authelia"
      POSTGRES_PASSWORD: "[snip]"

    image: redis:7
    restart: unless-stopped
    command: "redis-server --save 60 1 --loglevel warning --requirepass [snip]"
      - ./redis:/data

You’re going to need to make a few quick edits:

  1. Change the POSTGRES_PASSWORD under the database: service to the contents of the secrets/STORAGE_PASSWORD file.
  2. Change --requirepass in the Redis command: to the contents of the secrets/REDIS_PASSWORD file.

7) First run

Start by spinning up just Authelia, Postgres, and Redis. Don’t daemonize them yet:

docker compose up app database redis

Stick around and watch the logs. Ensure there are no glaring issues that pop up that might warrant your attention. Using CTRL+C will kill the containers when you’re done; either start troubleshooting any log errors now, or move on to…

Spin up just the Caddy container. Don’t daemonize it yet:

docker compose up proxy

Stick around and watch the logs. If something goes wrong with certificate requisition, Caddy should exit, and you’ll need to troubleshoot that now; double check DNS, your domain name, and ports 80 and 443 on your server are publicly accessible. If not, CTRL+C to kill the container.

If everything checked out A-OK:

  1. Edit docker-compose.yml and uncomment restart: unless-stopped on the Caddy service.
  2. Run docker compose up -d to start the entire stack.
  3. Browse to your Authelia and try to log in and configure your TOTP!

8) Securing other applications

a) From across the internet

Add the following snippet to your Caddyfile:

(secure) {
  forward_auth {args.0} {
    uri /api/verify?rd=
    copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
    header_up Host {upstream_hostport}

Then, import it in each site block that needs to be secured:

# Secure the entire subdomain {
  import secure *
  reverse_proxy upstream:8080

# Secure a subpath {
  import secure /api
  reverse_proxy backend:8080

# Secure based on a matcher {
  @myMatcher query key=val
  import secure @myMatcher
  reverse_proxy application:8080

b) On the same host

If you’re running other services in your Compose stack and need to secure them, you don’t need to forward_auth to the external domain - in fact, that’d be a bit inefficient! Instead, you can forward_auth directly to it:

(secure) {
  forward_auth {args.0} app:9091 {
    uri /api/verify?rd=
    copy_headers Remote-User Remote-Groups Remote-Name Remote-Email

Note that while the forward_auth stays the same, the uri continues to point to the external address, because while Caddy might be on the internal Compose network, the external client isn’t and needs to be redirected to the domain name.


First of all I want to say…THANK YOU!!

I’m a reasonably capable tinkerer but all the guides for Authelia implementations are for Traefik. I couldn’t translate them to something like what you’ve laid out here. Having some sort of SSO set up for my containers has been a dream for a while and I think this configuration will get me there. (i.e. it’s not working yet haha)

A couple changes…

Redis rdb permission issue (solved)

    image: redis:7
    container_name: auth_redis
    restart: unless-stopped
    command: "redis-server --save 60 1 --loglevel warning --requirepass [secret]"
      - ./redis:/data
    working_dir: /var/lib/redis

I had to add the working_dir line to resolve a permissions issue within redis regarding an rdb file.

Container names

I added container_name: to the compose for easier identification.

  • app → authelia
  • redis → auth_redis
  • database → auth_database


Authelia is inaccessible

Before running I…

  • Validated that the redis, database and authelia containers are running without error
  • Added the entry to my Caddyfile (the format you provide is the same as my other Caddy entries)

When I spin up all the containers and go to the or ‘192.168.1.XX:9091’ I get a 502 error.

Note: It did work ONCE but the password was incorrect so I rehashed it and updated the hash in the user file. Now I’m in this state.

Things I tried
  • Adding ports and exposed to the authelia container to explicitly expose port 9091
  • Meticulously combing through to see if my container name changes affected anything
  • Ensuring that my DNS is resolving to the correct IP address

I’m not seeing any errors which is kind of maddening because I’ve triple checked everything and it looks like it should be working.

Note: I was putting the incorrect port number in for local DNS! Also if the Caddyfile is not in the same directory as the Authelia docker-compose file use the local IP instead of the container name for the Caddy entry. Finally add quotations around the password in the user file and be sure to update ‘myusername’ with… well… your username (I didn’t). When I did that everything worked flawlessly. Thanks again!


Interesting! I didn’t need that at all, and this is, more or less, my current setup. If anyone else can chime in whether it was required for them, I wouldn’t mind editing the wiki post to add it anyway.

In my own setup, I used name: authelia at the top of the Compose file. This ensures Docker produces container names like authelia_app_1 and authelia_redis_1 etc. By default it uses the folder name the Compose file is inside (in this guide, the containing folder is called authelia anyway). I think I will add that to the wiki post regardless (and remove the no longer necessary compose version tag).

1 Like

11 posts were split to a new topic: Troubleshooting redirection issues with Authelia and Caddy