├── .env.example ├── .github ├── scripts │ ├── assert.sh │ ├── no-default-secrets.sh │ ├── test.sh │ └── wait-for-auth.sh └── workflows │ ├── lint.yaml │ └── test.yaml ├── .gitignore ├── .yamllint ├── CODEOWNERS ├── Makefile ├── README.md ├── authelia ├── configuration.yml ├── secrets.example │ ├── jwt │ ├── ldap-admin │ ├── ldap-config │ ├── postgres-password │ ├── session │ ├── smtp │ └── storage-encryption-key └── users.yml.example ├── bin ├── create-new-user ├── find-placeholder-secrets.sh └── populate-secrets.sh ├── docker-compose.labels.yml ├── docker-compose.yml ├── dynamic-conf └── config.yml ├── handle-errors ├── Dockerfile ├── go.mod ├── main.go └── status.html ├── renovate.json └── traefik.yml /.env.example: -------------------------------------------------------------------------------- 1 | COMPOSE_FILE=docker-compose.yml:docker-compose.labels.yml 2 | COMPOSE_PATH_SEPARATOR=: 3 | 4 | #------------------------------------------------------------------------------# 5 | # Host names # 6 | #------------------------------------------------------------------------------# 7 | # Uses localhost for local testing. These should be updated to reflect host names 8 | # you own. You can add additional hosts here as well. 9 | HANDLE_ERRORS_HOST=docker.localhost 10 | TRAEFIK_DASHBOARD_HOST=traefik.docker.localhost 11 | WHOAMI_HOST=whoami.docker.localhost 12 | SECURE_HOST=secure.docker.localhost 13 | AUTH_SERVER_HOST=auth.docker.localhost 14 | # The AUTH_REDIRECT should follow the pattern http://authelia:9091/api/verify?rd=https:/// 15 | AUTH_REDIRECT=http://authelia:9091/api/verify?rd=https://auth.docker.localhost/ 16 | 17 | #------------------------------------------------------------------------------# 18 | # Postgres Configuration # 19 | #------------------------------------------------------------------------------# 20 | POSTGRES_DB=authelia 21 | POSTGRES_USER=authelia 22 | -------------------------------------------------------------------------------- /.github/scripts/assert.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | host=$1 4 | expected_text=$2 5 | 6 | if [ -z "$3" ]; then 7 | header="" 8 | else 9 | header="Proxy-Authorization: Basic $3" 10 | fi 11 | 12 | result=$(curl -skH "${header}" --resolve "${host}":443:127.0.0.1 https://"${host}") 13 | if (echo "${result}" | grep -q "${expected_text}"); then 14 | echo "Pass" 15 | else 16 | echo "Failed! Response:" 17 | echo 18 | echo "${result}" 19 | exit 1 20 | fi 21 | -------------------------------------------------------------------------------- /.github/scripts/no-default-secrets.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) 4 | # shellcheck disable=SC2207 5 | DEFAULT_SECRETS_FILES=($("${SCRIPT_DIR}"/../../bin/find-placeholder-secrets.sh)) 6 | 7 | if [ ${#DEFAULT_SECRETS_FILES[@]} -eq 0 ]; then 8 | exit 0 9 | else 10 | echo "FAIL: Placeholder secrets detected..." 11 | exit 1 12 | fi 13 | -------------------------------------------------------------------------------- /.github/scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export SHELLCHECK_OPTS="-x -e SC2034 --shell=bash" 4 | 5 | shebangregex="^#! */[^ ]*/(env *)?[abkz]*sh" 6 | 7 | filepaths=() 8 | excludes=(! -path *./.git/*) 9 | excludes+=(! -path *.go) 10 | excludes+=(! -path */mvnw) 11 | excludes+=(! -path */meta/dotbot/*) 12 | 13 | readarray -d '' filepaths < <(find . -type f "${excludes[@]}" \ 14 | '(' \ 15 | -name '*.bash' \ 16 | -o -name '.bashrc' \ 17 | -o -name 'bashrc' \ 18 | -o -name '.bash_aliases' \ 19 | -o -name '.bash_completion' \ 20 | -o -name '.bash_login' \ 21 | -o -name '.bash_logout' \ 22 | -o -name '.bash_profile' \ 23 | -o -name 'bash_profile' \ 24 | -o -name '*.ksh' \ 25 | -o -name 'suid_profile' \ 26 | -o -name '*.zsh' \ 27 | -o -name '.zlogin' \ 28 | -o -name 'zlogin' \ 29 | -o -name '.zlogout' \ 30 | -o -name 'zlogout' \ 31 | -o -name '.zprofile' \ 32 | -o -name 'zprofile' \ 33 | -o -name '.zsenv' \ 34 | -o -name 'zsenv' \ 35 | -o -name '.zshrc' \ 36 | -o -name 'zshrc' \ 37 | -o -name '*.sh' \ 38 | -o -path '*/.profile' \ 39 | -o -path '*/profile' \ 40 | -o -name '*.shlib' \ 41 | ')' \ 42 | -print0) 43 | 44 | readarray -d '' tmp < <(find . "${excludes[@]}" -type f ! -name '*.*' -print0) 45 | for file in "${tmp[@]}"; do 46 | head -n1 "$file" | grep -Eqs "$shebangregex" || continue 47 | filepaths+=("$file") 48 | done 49 | 50 | echo "Running shellcheck..." 51 | shellcheck "${filepaths[@]}" 52 | 53 | echo "Running shfmt..." 54 | shfmt -l -w -s . 55 | 56 | echo "Running yamllint..." 57 | yamllint . 58 | 59 | echo "Ensure no placeholder secrets..." 60 | .github/scripts/no-default-secrets.sh 61 | -------------------------------------------------------------------------------- /.github/scripts/wait-for-auth.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | wait_for_it() { 4 | status_code=$(curl -sk --output /dev/null --write-out "%{http_code}" --resolve auth.docker.localhost:443:127.0.0.1 https://auth.docker.localhost) 5 | if ((status_code != 200)); then 6 | return 0 7 | else 8 | return 1 9 | fi 10 | } 11 | 12 | counter=15 13 | while wait_for_it; do 14 | if ((counter == 0)); then 15 | echo "Timeout! Authelia didn't start in time" 16 | exit 1 17 | fi 18 | echo "Waiting for authelia to start up... ($counter more tries remaining)" 19 | sleep 5 20 | ((counter -= 1)) 21 | done 22 | 23 | echo "Authelia is up!" 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | on: pull_request 3 | 4 | jobs: 5 | yaml-lint: 6 | name: Run yaml linting 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v5.0.0 10 | - name: Configure Python 3.12 11 | uses: actions/setup-python@v5 12 | with: 13 | python-version: "3.13" 14 | - name: Install yamllint 15 | run: pip install yamllint==1.34.0 16 | - name: Run yamllint 17 | run: yamllint . 18 | shellcheck: 19 | name: Shellcheck 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v5.0.0 23 | - name: Run ShellCheck 24 | uses: ludeeus/action-shellcheck@2.0.0 25 | with: 26 | check_together: "yes" 27 | env: 28 | SHELLCHECK_OPTS: -e SC2034 --shell=bash 29 | shfmt: 30 | name: Shell format 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v5.0.0 34 | - uses: actions/setup-go@v5 35 | with: 36 | go-version: 1.25.x 37 | - name: Install shfmt 38 | run: go install mvdan.cc/sh/v3/cmd/shfmt@latest 39 | - name: Check formatting 40 | run: shfmt -s -d . 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | # yamllint disable rule:line-length 2 | name: Testing 3 | on: push 4 | 5 | jobs: 6 | test: 7 | name: Make sure services start 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v5.0.0 11 | - name: Configure 12 | run: make 13 | - name: Ensure no placeholder secrets 14 | run: .github/scripts/no-default-secrets.sh 15 | - name: Generate auth information 16 | run: | 17 | echo "basic_auth_header=$(echo -n "admin-changeme:insecure" | base64)" >> $GITHUB_ENV 18 | - name: Wait for services to start up 19 | run: .github/scripts/wait-for-auth.sh 20 | - name: Make sure whoami service starts up 21 | run: .github/scripts/assert.sh "whoami.docker.localhost" "IP" 22 | - name: Make sure traefik service prompts for auth 23 | run: .github/scripts/assert.sh "traefik.docker.localhost" "https://auth.docker.localhost" 24 | - name: Make sure traefik service can be logged in 25 | run: 26 | .github/scripts/assert.sh "traefik.docker.localhost" "Found" "${{ 27 | env.basic_auth_header }}" 28 | - name: Make sure traefik service cannot be logged in with bad password 29 | run: .github/scripts/assert.sh "traefik.docker.localhost" "Unauthorized" "bad_auth_header" 30 | - name: Make sure secure service prompts for auth 31 | run: .github/scripts/assert.sh "secure.docker.localhost" "https://auth.docker.localhost" 32 | - name: Make sure secure service cannot be accessed without 2fa 33 | run: .github/scripts/assert.sh "secure.docker.localhost" "Unauthorized" "${{ env.basic_auth_header }}" 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #################### 2 | ### CUSTOM ### 3 | #################### 4 | 5 | # Let's Encrypt Private Keys 6 | acme/* 7 | !acme/.gitkeep 8 | 9 | # Environment Variables 10 | .env 11 | 12 | authelia/secrets 13 | authelia/notification.txt 14 | authelia/users.yml 15 | authelia/users.yaml 16 | 17 | # Created by https://www.toptal.com/developers/gitignore/api/go,visualstudiocode,python,macos 18 | # Edit at https://www.toptal.com/developers/gitignore?templates=go,visualstudiocode,python,macos 19 | 20 | ### Go ### 21 | # If you prefer the allow list template instead of the deny list, see community template: 22 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 23 | # 24 | # Binaries for programs and plugins 25 | *.exe 26 | *.exe~ 27 | *.dll 28 | *.so 29 | *.dylib 30 | 31 | # Test binary, built with `go test -c` 32 | *.test 33 | 34 | # Output of the go coverage tool, specifically when used with LiteIDE 35 | *.out 36 | 37 | # Dependency directories (remove the comment below to include it) 38 | # vendor/ 39 | 40 | # Go workspace file 41 | go.work 42 | 43 | ### macOS ### 44 | # General 45 | .DS_Store 46 | .AppleDouble 47 | .LSOverride 48 | 49 | # Icon must end with two \r 50 | Icon 51 | 52 | 53 | # Thumbnails 54 | ._* 55 | 56 | # Files that might appear in the root of a volume 57 | .DocumentRevisions-V100 58 | .fseventsd 59 | .Spotlight-V100 60 | .TemporaryItems 61 | .Trashes 62 | .VolumeIcon.icns 63 | .com.apple.timemachine.donotpresent 64 | 65 | # Directories potentially created on remote AFP share 66 | .AppleDB 67 | .AppleDesktop 68 | Network Trash Folder 69 | Temporary Items 70 | .apdisk 71 | 72 | ### macOS Patch ### 73 | # iCloud generated files 74 | *.icloud 75 | 76 | ### Python ### 77 | # Byte-compiled / optimized / DLL files 78 | __pycache__/ 79 | *.py[cod] 80 | *$py.class 81 | 82 | # C extensions 83 | 84 | # Distribution / packaging 85 | .Python 86 | build/ 87 | develop-eggs/ 88 | dist/ 89 | downloads/ 90 | eggs/ 91 | .eggs/ 92 | lib/ 93 | lib64/ 94 | parts/ 95 | sdist/ 96 | var/ 97 | wheels/ 98 | share/python-wheels/ 99 | *.egg-info/ 100 | .installed.cfg 101 | *.egg 102 | MANIFEST 103 | 104 | # PyInstaller 105 | # Usually these files are written by a python script from a template 106 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 107 | *.manifest 108 | *.spec 109 | 110 | # Installer logs 111 | pip-log.txt 112 | pip-delete-this-directory.txt 113 | 114 | # Unit test / coverage reports 115 | htmlcov/ 116 | .tox/ 117 | .nox/ 118 | .coverage 119 | .coverage.* 120 | .cache 121 | nosetests.xml 122 | coverage.xml 123 | *.cover 124 | *.py,cover 125 | .hypothesis/ 126 | .pytest_cache/ 127 | cover/ 128 | 129 | # Translations 130 | *.mo 131 | *.pot 132 | 133 | # Django stuff: 134 | *.log 135 | local_settings.py 136 | db.sqlite3 137 | db.sqlite3-journal 138 | 139 | # Flask stuff: 140 | instance/ 141 | .webassets-cache 142 | 143 | # Scrapy stuff: 144 | .scrapy 145 | 146 | # Sphinx documentation 147 | docs/_build/ 148 | 149 | # PyBuilder 150 | .pybuilder/ 151 | target/ 152 | 153 | # Jupyter Notebook 154 | .ipynb_checkpoints 155 | 156 | # IPython 157 | profile_default/ 158 | ipython_config.py 159 | 160 | # pyenv 161 | # For a library or package, you might want to ignore these files since the code is 162 | # intended to run in multiple environments; otherwise, check them in: 163 | # .python-version 164 | 165 | # pipenv 166 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 167 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 168 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 169 | # install all needed dependencies. 170 | #Pipfile.lock 171 | 172 | # poetry 173 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 174 | # This is especially recommended for binary packages to ensure reproducibility, and is more 175 | # commonly ignored for libraries. 176 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 177 | #poetry.lock 178 | 179 | # pdm 180 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 181 | #pdm.lock 182 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 183 | # in version control. 184 | # https://pdm.fming.dev/#use-with-ide 185 | .pdm.toml 186 | 187 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 188 | __pypackages__/ 189 | 190 | # Celery stuff 191 | celerybeat-schedule 192 | celerybeat.pid 193 | 194 | # SageMath parsed files 195 | *.sage.py 196 | 197 | # Environments 198 | .env 199 | .venv 200 | env/ 201 | venv/ 202 | ENV/ 203 | env.bak/ 204 | venv.bak/ 205 | 206 | # Spyder project settings 207 | .spyderproject 208 | .spyproject 209 | 210 | # Rope project settings 211 | .ropeproject 212 | 213 | # mkdocs documentation 214 | /site 215 | 216 | # mypy 217 | .mypy_cache/ 218 | .dmypy.json 219 | dmypy.json 220 | 221 | # Pyre type checker 222 | .pyre/ 223 | 224 | # pytype static type analyzer 225 | .pytype/ 226 | 227 | # Cython debug symbols 228 | cython_debug/ 229 | 230 | # PyCharm 231 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 232 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 233 | # and can be added to the global gitignore or merged into this file. For a more nuclear 234 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 235 | #.idea/ 236 | 237 | ### Python Patch ### 238 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 239 | poetry.toml 240 | 241 | # ruff 242 | .ruff_cache/ 243 | 244 | # LSP config files 245 | pyrightconfig.json 246 | 247 | ### VisualStudioCode ### 248 | .vscode/* 249 | !.vscode/settings.json 250 | !.vscode/tasks.json 251 | !.vscode/launch.json 252 | !.vscode/extensions.json 253 | !.vscode/*.code-snippets 254 | 255 | # Local History for Visual Studio Code 256 | .history/ 257 | 258 | # Built Visual Studio Code Extensions 259 | *.vsix 260 | 261 | ### VisualStudioCode Patch ### 262 | # Ignore all local history of files 263 | .history 264 | .ionide 265 | 266 | # End of https://www.toptal.com/developers/gitignore/api/go,visualstudiocode,python,macos 267 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | rules: 5 | document-start: disable 6 | comments: 7 | require-starting-space: true 8 | min-spaces-from-content: 1 9 | key-duplicates: {} 10 | comments-indentation: false 11 | empty-lines: {max: 1} 12 | quoted-strings: 13 | quote-type: double 14 | required: only-when-needed 15 | extra-allowed: [^~.*] 16 | line-length: 17 | max: 84 18 | level: warning 19 | new-line-at-end-of-file: enable 20 | new-lines: enable 21 | trailing-spaces: enable 22 | truthy: 23 | allowed-values: ["true", "false", "on"] 24 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jamescurtin 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: create-network 2 | .create-network: 3 | @docker network create traefik 2> /dev/null && echo "Created network traefik" || echo "Network traefik already exists" 4 | 5 | .PHONY: create-volume 6 | .create-volume: 7 | @docker volume create authelia-postgres-data 2> /dev/null && echo "Created volume authelia-postgres-data" || echo "Network authelia-postgres-data already exists" 8 | 9 | .PHONY: configure-local-settings 10 | .configure-local-settings: 11 | @mkdir -p acme 12 | @touch -a acme/acme.json && chmod 600 acme/acme.json 13 | @cp -n .env.example .env || true 14 | @cp -n authelia/users.yml.example authelia/users.yml || true 15 | @cp -n -R authelia/secrets.example authelia/secrets && chmod 600 authelia/secrets/* || true 16 | @./bin/populate-secrets.sh 17 | 18 | .PHONY: build 19 | .build: 20 | @echo "Building docker images..." && docker compose build 2>&1 >/dev/null 21 | 22 | .PHONY: up 23 | .up: 24 | @echo "Starting service..." && docker compose up -d 25 | 26 | .PHONY: down 27 | .down: 28 | @docker compose down -v || true 29 | 30 | .PHONY: target 31 | target: .create-network .create-volume .configure-local-settings .build .down .up 32 | 33 | .PHONY: clean 34 | clean: 35 | 36 | docker compose down -v || true 37 | rm -f acme/* 38 | rm -rf authelia/secrets 39 | rm -rf authelia/users.yml 40 | rm -f .env 41 | 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Traefik-Proxy 2 | 3 | [![Linting](https://github.com/jamescurtin/traefik-proxy/actions/workflows/lint.yaml/badge.svg)](https://github.com/jamescurtin/traefik-proxy/actions/workflows/lint.yaml) 4 | [![T](https://github.com/jamescurtin/traefik-proxy/actions/workflows/test.yaml/badge.svg)](https://github.com/jamescurtin/traefik-proxy/actions/workflows/test.yaml) 5 | 6 | One-step (secure) configuration for [Traefik](https://docs.traefik.io/) edge router using [Authelia](https://www.authelia.com/) for authentication. 7 | 8 | ## Features 9 | 10 | Keeping in mind security first, this project ensures: 11 | 12 | * The Docker daemon socket is never mounted to traefik or any container with external networking (See the [risks](https://docs.docker.com/engine/security/#docker-daemon-attack-surface) of exposing the Docker daemon) 13 | * HTTPS redirection is automatically configured for all routers 14 | * TLS is always enabled, even locally (can confidently test new services locally without needing a dev config that differs significantly from prod) 15 | * The Traefik dashboard is never launched in insecure mode 16 | 17 | Other features include: 18 | 19 | * Self-hosted SSO authentication ([Authelia](https://www.authelia.com/)), including support for security keys and one-time password generators 20 | * User-friendly 4XX & 5XX status pages 21 | * Pre-configured file provider (for shared routers and middleware) and Docker provider (for everything else) 22 | * Centralized configuration via environment variables and Docker secrets 23 | 24 | ## Getting Started 25 | 26 | ### Quickstart 27 | 28 | ```console 29 | $ git clone https://github.com/jamescurtin/traefik-proxy.git 30 | $ cd traefik-proxy 31 | $ make 32 | ``` 33 | 34 | Running `make` creates an `.env` file and the `authelia/secrets` directory. The 35 | `.env` file should be updated to include hostnames for additional hosts that are 36 | configured. The `authelia/secrets` directory contains secrets for configuring 37 | all services. If you follow the quickstart and run `make`, random passwords are generated by default. Otherwise, you must replace the values in `authelia/secrets` before deploying. 38 | 39 | There are additional configuration files that need to be customized before you can 40 | deploy in a production environment. All places where customization is necessary 41 | are marked with `CHANGEME` comments. 42 | 43 | The command will also create the external docker network `traefik`. Other docker 44 | services that you plan to expose via Traefik should be added to this network. 45 | 46 | See the [Exploring](#exploring) section for more information. 47 | 48 | ## Users 49 | 50 | This is configured to use two-factor auth. When running the project out of the box (_i.e._ without having configured the SMTP notifier), you will have to check the file `authelia/notification.txt` to get the registration link for configuring 2FA. 51 | 52 | Authelia users are defined in `authelia/users.yml`. 53 | 54 | By default, this ships with two users (both have the password `insecure`). 55 | One is a member of a group called `admin`, and the other has no group memberships. 56 | See the [Exploring](#exploring) section to see how group membership can be used 57 | for access control. 58 | 59 | ### Creating a user 60 | 61 | You will need to create a new user and add them to `authelia/users.yml`. 62 | As a convenience, you can run the command 63 | 64 | ```bash 65 | $ bin/create-new-user 66 | Enter username: 67 | ... 68 | ``` 69 | 70 | which will prompt for the user's information, and add an entry to the user file 71 | (with a hashed password). 72 | 73 | **Make sure to remove the default users before deploying!** 74 | 75 | ## Exploring 76 | 77 | **Note**: When run locally (_e.g._ on `localhost`), Traefik uses a self-signed SSL certificate. Therefore, web-browser security warnings are expected and can be safely bypassed. 78 | When deployed on any other domain, it will use Let's Encrypt certificates. 79 | 80 | To explore, navigate to: 81 | 82 | * [https://traefik.docker.localhost](https://traefik.docker.localhost) (Traefik configuration dashboard) 83 | * Requires login: see the [Users](#users) section for more information. 84 | * [https://whoami.docker.localhost](https://whoami.docker.localhost) ("Hello world" example) 85 | * [https://secure.docker.localhost](https://secure.docker.localhost) ("Hello world" example demonstrating ACLs and 2FA) 86 | * See the [Users](#users) section for more information about the default users. 87 | * See the `access_control` section of `authelia/configuration.yml` to understand how access is configured. 88 | * First, attempt to log in with the user `user-changeme`. Access should be denied, because the user isn't a member of the required group 89 | * Next, go to auth.docker.localhost and log out. 90 | * Then, go back to secure.docker.localhost to log in with user `admin-changeme`. Access should be granted, based on user group. 91 | * See the [Users](#users) section for information on how 2FA is configured by default. 92 | * [https://auth.docker.localhost](https://auth.docker.localhost) (SSO Auth service) 93 | * [https://traefik.docker.localhost/nonexistent](https://traefik.docker.localhost/nonexistent) (This page doesn't exist, and is therefore re-routed to a custom error page) 94 | 95 | ## Testing 96 | 97 | Run the test suite locally via 98 | 99 | ```bash 100 | .github/scripts/test.sh 101 | ``` 102 | -------------------------------------------------------------------------------- /authelia/configuration.yml: -------------------------------------------------------------------------------- 1 | # See https://www.authelia.com/configuration/prologue/introduction/ for options 2 | server: 3 | address: 0.0.0.0:9091 4 | 5 | log: 6 | level: debug 7 | 8 | totp: 9 | issuer: auth.docker.localhost # CHANGEME: Set as AUTH_SERVER_HOST 10 | period: 30 11 | skew: 1 12 | 13 | authentication_backend: 14 | file: 15 | path: /config/users.yml 16 | password: 17 | algorithm: argon2id 18 | iterations: 3 19 | key_length: 32 20 | salt_length: 16 21 | parallelism: 4 22 | memory: 64 23 | 24 | # Read https://www.authelia.com/configuration/security/access-control/ carefully 25 | # It is easy to misconfigure these. 26 | access_control: 27 | default_policy: one_factor 28 | rules: 29 | # CHANGEME: Configure for your hosts. 30 | # The auth domain should always be set to bypass. 31 | - domain: auth.docker.localhost 32 | policy: bypass 33 | - domain: whoami.docker.localhost 34 | policy: bypass 35 | - domain: traefik.docker.localhost 36 | policy: one_factor 37 | - domain: secure.docker.localhost 38 | policy: two_factor 39 | subject: 40 | - group:admin 41 | 42 | session: 43 | name: authelia_session 44 | expiration: 3600 # 1 hour 45 | inactivity: 300 # 5 minutes 46 | cookies: 47 | - domain: docker.localhost # CHANGEME: domain associated with the login subdomain 48 | authelia_url: https://auth.docker.localhost # CHANGEME: Set as AUTH_SERVER_HOST 49 | default_redirection_url: https://docker.localhost # CHANGEME: Set as desired redirection URL 50 | 51 | redis: 52 | host: authelia-redis 53 | port: 6379 54 | 55 | regulation: 56 | max_retries: 3 57 | find_time: 120 58 | ban_time: 300 59 | 60 | storage: 61 | postgres: 62 | address: authelia-postgres:5432 63 | database: authelia 64 | username: authelia 65 | 66 | notifier: 67 | disable_startup_check: true 68 | filesystem: 69 | # CHANGEME: Use the SMTP configuration (see CHANGEME in docker-compose.yml) 70 | filename: /config/notification.txt 71 | # smtp: 72 | # username: changeme@gmail.com 73 | # sender: no-reply@auth.docker.localhost 74 | # host: smtp.gmail.com 75 | # port: 587 76 | 77 | theme: auto 78 | -------------------------------------------------------------------------------- /authelia/secrets.example/jwt: -------------------------------------------------------------------------------- 1 | default-secret-to-be-replaced 2 | -------------------------------------------------------------------------------- /authelia/secrets.example/ldap-admin: -------------------------------------------------------------------------------- 1 | default-secret-to-be-replaced 2 | -------------------------------------------------------------------------------- /authelia/secrets.example/ldap-config: -------------------------------------------------------------------------------- 1 | default-secret-to-be-replaced 2 | -------------------------------------------------------------------------------- /authelia/secrets.example/postgres-password: -------------------------------------------------------------------------------- 1 | default-secret-to-be-replaced 2 | -------------------------------------------------------------------------------- /authelia/secrets.example/session: -------------------------------------------------------------------------------- 1 | default-secret-to-be-replaced 2 | -------------------------------------------------------------------------------- /authelia/secrets.example/smtp: -------------------------------------------------------------------------------- 1 | default-secret-to-be-replaced 2 | -------------------------------------------------------------------------------- /authelia/secrets.example/storage-encryption-key: -------------------------------------------------------------------------------- 1 | default-secret-to-be-replaced 2 | -------------------------------------------------------------------------------- /authelia/users.yml.example: -------------------------------------------------------------------------------- 1 | # yamllint disable rule:line-length 2 | users: 3 | admin-changeme: 4 | displayname: Admin 5 | # The password is "insecure" 6 | password: $argon2id$v=19$m=65536,t=3,p=4$bnZhQTB3TlEyVzgzYUg4UQ$vcdNixglSbsSgi6jaeLUGWL0X48wA9odAKFWwjb2aFo 7 | email: admin-changeme@foobar.com 8 | groups: 9 | - admin 10 | user-changeme: 11 | displayname: User 12 | # The password is "insecure" 13 | password: $argon2id$v=19$m=65536,t=3,p=4$cUJGZllTTjNvNmU4UmR4Sg$quIS1X8prKerzO9vwnnOlklg9EaxwXoYbrqj1oN+HEg 14 | email: user-changeme@foobar.com 15 | groups: [] 16 | -------------------------------------------------------------------------------- /bin/create-new-user: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) 4 | 5 | if [ ! -f "${SCRIPT_DIR}"/../authelia/users.yml ]; then 6 | echo "The file authelia/users.yml does not exist!" 7 | echo "Follow the instructions in the README to create it." 8 | exit 1 9 | fi 10 | 11 | echo -n "Enter username: " 12 | read -r username 13 | echo 14 | 15 | echo -n "Enter user's display name: " 16 | read -r displayname 17 | echo 18 | 19 | echo -n "Enter user's email: " 20 | read -r email 21 | echo 22 | 23 | echo -n "Enter user's password:" 24 | read -rs password 25 | echo 26 | echo 27 | echo 28 | echo "Creating user..." 29 | 30 | hashed_password=$(docker run --rm -v "${SCRIPT_DIR}"/../authelia:/config authelia/authelia:latest authelia crypto hash generate argon2 --config /config/configuration.yml --password "$password" | sed -e "s/^Digest: //") 31 | 32 | cat <>"${SCRIPT_DIR}"/../authelia/users.yml 33 | $username: 34 | displayname: "$displayname" 35 | password: "$hashed_password" 36 | email: $email 37 | groups: [] 38 | EOT 39 | 40 | echo "New user has been added to authelia/users.yml." 41 | -------------------------------------------------------------------------------- /bin/find-placeholder-secrets.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | CURRENT_SCRIPT=$(basename "$0") 3 | 4 | grep -HRl --exclude-dir="*/authelia/secrets.example" --exclude="*${CURRENT_SCRIPT}" "default-secret-to-be-replaced" 5 | -------------------------------------------------------------------------------- /bin/populate-secrets.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | PASSWORD_LENGTH=50 4 | # shellcheck disable=SC2207 5 | DEFAULT_SECRETS_FILES=($(./bin/find-placeholder-secrets.sh)) 6 | 7 | for i in "${DEFAULT_SECRETS_FILES[@]}"; do 8 | randomly_generated_pass=$(head -c $PASSWORD_LENGTH /dev/random | base32) 9 | echo "${randomly_generated_pass:0:PASSWORD_LENGTH}" >"${i}" 10 | echo "Replacing password in ${i}" 11 | done 12 | -------------------------------------------------------------------------------- /docker-compose.labels.yml: -------------------------------------------------------------------------------- 1 | services: 2 | authelia: 3 | labels: 4 | - traefik.enable=true 5 | - traefik.http.routers.authelia.entrypoints=websecure 6 | - traefik.http.routers.authelia.rule=Host(`${AUTH_SERVER_HOST}`) 7 | - traefik.http.routers.authelia.tls.certresolver=letsencrypt 8 | - traefik.http.routers.authelia.tls=true 9 | handle-errors: 10 | labels: 11 | - traefik.enable=true 12 | - traefik.http.routers.handle-errors.entrypoints=websecure 13 | - traefik.http.routers.handle-errors.rule=Host(`${HANDLE_ERRORS_HOST}`) 14 | - traefik.http.routers.handle-errors.priority=1 15 | - traefik.http.routers.handle-errors.tls.certresolver=letsencrypt 16 | - traefik.http.routers.handle-errors.tls=true 17 | traefik: 18 | labels: 19 | - traefik.enable=true 20 | - traefik.http.middlewares.auth.forwardauth.address=${AUTH_REDIRECT} 21 | - traefik.http.middlewares.auth.forwardauth.trustForwardHeader=true 22 | - traefik.http.middlewares.auth.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email 23 | - traefik.http.routers.traefik.entrypoints=websecure 24 | - traefik.http.routers.traefik.middlewares=secured@file 25 | - traefik.http.routers.traefik.rule=Host(`${TRAEFIK_DASHBOARD_HOST}`) 26 | - traefik.http.routers.traefik.service=api@internal 27 | - traefik.http.routers.traefik.tls.certresolver=letsencrypt 28 | - traefik.http.routers.traefik.tls=true 29 | - traefik.http.routers.traefik.tls.domains[0].sans=*.docker.localhost 30 | whoami: 31 | labels: 32 | - traefik.enable=true 33 | - traefik.http.routers.whoami.entrypoints=websecure 34 | - traefik.http.routers.whoami.middlewares=secured@file 35 | - traefik.http.routers.whoami.rule=Host(`${WHOAMI_HOST}`) 36 | - traefik.http.routers.whoami.tls.certresolver=letsencrypt 37 | - traefik.http.routers.whoami.tls=true 38 | secure-whoami: 39 | labels: 40 | - traefik.enable=true 41 | - traefik.http.routers.secure-whoami.entrypoints=websecure 42 | - traefik.http.routers.secure-whoami.middlewares=secured@file 43 | - traefik.http.routers.secure-whoami.rule=Host(`${SECURE_HOST}`) 44 | - traefik.http.routers.secure-whoami.tls.certresolver=letsencrypt 45 | - traefik.http.routers.secure-whoami.tls=true 46 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | authelia: 3 | container_name: authelia 4 | depends_on: 5 | - postgres 6 | - redis 7 | env_file: 8 | - .env 9 | environment: 10 | AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE: /run/secrets/jwt 11 | # yamllint disable-line rule:line-length 12 | # CHANGEME: Uncomment the following line if using notifier.smtp in authelia/configuration.yml 13 | # AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE: /run/secrets/smtp 14 | AUTHELIA_SESSION_SECRET_FILE: /run/secrets/session 15 | AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password 16 | AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE: /run/secrets/storage-encryption-key 17 | # CHANGEME: Your timezone 18 | TZ: America/New_York 19 | secrets: 20 | - jwt 21 | - postgres-password 22 | - session 23 | - smtp 24 | - storage-encryption-key 25 | image: authelia/authelia:4.39.6 26 | networks: 27 | - authelia 28 | - traefik 29 | restart: always 30 | volumes: 31 | - ./authelia:/config:ro 32 | - ./authelia/secrets:/etc/secrets 33 | 34 | docker-socket-proxy: 35 | container_name: docker-socket-proxy 36 | environment: 37 | CONTAINERS: 1 38 | image: tecnativa/docker-socket-proxy:0.3.0 39 | networks: 40 | - docker-socket-proxy 41 | restart: always 42 | volumes: 43 | - /var/run/docker.sock:/var/run/docker.sock 44 | 45 | handle-errors: 46 | build: 47 | context: ./handle-errors 48 | networks: 49 | - traefik 50 | restart: always 51 | 52 | postgres: 53 | container_name: authelia-postgres 54 | env_file: 55 | - .env 56 | environment: 57 | POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password 58 | secrets: 59 | - postgres-password 60 | image: postgres:17.5-alpine 61 | networks: 62 | - authelia 63 | restart: always 64 | volumes: 65 | - authelia-postgres-data:/var/lib/postgresql/data 66 | 67 | redis: 68 | container_name: authelia-redis 69 | image: redis:8.2.0-alpine 70 | restart: always 71 | networks: 72 | - authelia 73 | 74 | traefik: 75 | depends_on: 76 | - authelia 77 | - docker-socket-proxy 78 | - handle-errors 79 | env_file: 80 | - .env 81 | image: traefik:v3.5.0 82 | networks: 83 | - docker-socket-proxy 84 | - traefik 85 | ports: 86 | - 80:80 87 | - 443:443 88 | restart: always 89 | volumes: 90 | - ./acme:/etc/acme 91 | - ./dynamic-conf:/etc/traefik/dynamic-conf:ro 92 | - ./traefik.yml:/etc/traefik/traefik.yml:ro 93 | 94 | whoami: 95 | image: traefik/whoami:v1.11 96 | networks: 97 | - traefik 98 | 99 | secure-whoami: 100 | image: traefik/whoami:v1.11 101 | networks: 102 | - traefik 103 | 104 | secrets: 105 | jwt: 106 | file: authelia/secrets/jwt 107 | postgres-password: 108 | file: authelia/secrets/postgres-password 109 | session: 110 | file: authelia/secrets/session 111 | smtp: 112 | file: authelia/secrets/smtp 113 | storage-encryption-key: 114 | file: authelia/secrets/storage-encryption-key 115 | 116 | networks: 117 | authelia: 118 | docker-socket-proxy: 119 | traefik: 120 | external: true 121 | 122 | volumes: 123 | authelia-postgres-data: 124 | external: true 125 | -------------------------------------------------------------------------------- /dynamic-conf/config.yml: -------------------------------------------------------------------------------- 1 | x-default-router: &default-router 2 | middlewares: 3 | - secured 4 | tls: 5 | certResolver: letsencrypt 6 | 7 | http: 8 | middlewares: 9 | secured: 10 | chain: 11 | middlewares: 12 | - auth@docker 13 | - redirect-to-https 14 | - error-pages 15 | 16 | redirect-to-https: 17 | redirectScheme: 18 | scheme: https 19 | 20 | error-pages: 21 | errors: 22 | status: 23 | - 400-499 24 | - 500-599 25 | service: handle-errors-traefik-proxy@docker 26 | query: /{status} 27 | 28 | routers: 29 | http-catchall: 30 | entrypoints: 31 | - web 32 | middlewares: 33 | - redirect-to-https 34 | - error-pages 35 | priority: 9999 36 | rule: HostRegexp(`{host:.+}`) 37 | service: noop 38 | 39 | # Example of a router 40 | my-router: 41 | <<: *default-router 42 | rule: Host(`mysubdomain.docker.local`) 43 | service: my-service 44 | 45 | services: 46 | noop: 47 | loadBalancer: 48 | servers: 49 | - url: http://192.168.0.1 50 | 51 | # Example of a service 52 | my-service: 53 | loadBalancer: 54 | servers: 55 | - url: http://192.168.1.23:8123 56 | -------------------------------------------------------------------------------- /handle-errors/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25-alpine AS builder 2 | 3 | ENV USER=appuser 4 | ENV UID=10001 5 | 6 | RUN adduser \ 7 | --disabled-password \ 8 | --gecos "" \ 9 | --home "/nonexistent" \ 10 | --shell "/sbin/nologin" \ 11 | --no-create-home \ 12 | --uid "${UID}" \ 13 | "${USER}" 14 | 15 | WORKDIR $GOPATH/src 16 | COPY main.go go.mod ./ 17 | 18 | RUN CGO_ENABLED=0 GOOS=linux go build \ 19 | -ldflags='-w -s -extldflags "-static"' -a \ 20 | -o /go/bin/serve . 21 | 22 | FROM scratch 23 | 24 | COPY --from=builder /etc/passwd /etc/passwd 25 | COPY --from=builder /etc/group /etc/group 26 | COPY --from=builder /go/bin/serve /go/bin/serve 27 | 28 | COPY status.html status.html 29 | 30 | USER appuser:appuser 31 | EXPOSE 8080 32 | 33 | ENTRYPOINT ["/go/bin/serve"] 34 | -------------------------------------------------------------------------------- /handle-errors/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jamescurtin/traefik-proxy/handle-errors 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /handle-errors/main.go: -------------------------------------------------------------------------------- 1 | // Webserver that returns pretty HTTP error codes 2 | package main 3 | 4 | import ( 5 | "html/template" 6 | "log" 7 | "net/http" 8 | "strconv" 9 | ) 10 | 11 | // Determines if input value is a valid HTTP error code 12 | func isHTTPErrorCode(i int) bool { 13 | if (i >= 400) && (i <= 599) { 14 | return true 15 | } else { 16 | return false 17 | } 18 | } 19 | 20 | // Renders HTML template with the provided status code. The status code is both 21 | // inserted as text in the HTML body and set as the status of the response. 22 | func renderTemplate(w http.ResponseWriter, statusCode int) { 23 | w.WriteHeader(statusCode) 24 | 25 | t, err := template.ParseFiles("status.html") 26 | if err != nil { 27 | http.Error(w, err.Error(), http.StatusInternalServerError) 28 | return 29 | } 30 | 31 | err = t.Execute(w, statusCode) 32 | if err != nil { 33 | http.Error(w, err.Error(), http.StatusInternalServerError) 34 | } 35 | } 36 | 37 | // Handles incoming requests. If the path of the request is a valid HTTP error 38 | // code, return a landing page for that status. Otherwise, return a 404 39 | func handler(w http.ResponseWriter, r *http.Request) { 40 | requestPath := r.URL.Path[1:] 41 | 42 | requestPathInt, err := strconv.Atoi(requestPath) 43 | if err != nil { 44 | renderTemplate(w, 404) 45 | return 46 | } 47 | 48 | if isErrorCode := isHTTPErrorCode(requestPathInt); isErrorCode == false { 49 | renderTemplate(w, 404) 50 | return 51 | } 52 | 53 | renderTemplate(w, requestPathInt) 54 | } 55 | 56 | func main() { 57 | http.HandleFunc("/", handler) 58 | log.Fatal(http.ListenAndServe(":8080", nil)) 59 | } 60 | -------------------------------------------------------------------------------- /handle-errors/status.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 250 | 251 | 252 | 253 |
254 |
255 |

{{.}}

256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
    269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
    286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
    303 |
304 |
305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 |
313 |
314 |
315 |
316 |
317 |
318 |
319 |
    320 |
321 |
322 |
323 |
324 |
325 |
326 |
327 |
328 |
329 |
330 |
331 |
332 |
333 |
334 |
335 |
336 |
    337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
348 |
349 |
350 |
351 |
352 |
353 |
    354 |
355 |
356 |
357 |
358 |
359 |
360 |
361 | 362 |
363 | 422 | 423 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /traefik.yml: -------------------------------------------------------------------------------- 1 | accessLog: {} 2 | 3 | api: 4 | dashboard: true 5 | 6 | log: 7 | level: DEBUG # CHANGEME: Use higher log level instead 8 | # level: INFO 9 | 10 | certificatesResolvers: 11 | letsencrypt: 12 | acme: 13 | # CHANGEME: Use a real email (to receive notifications from Let's Encrypt) 14 | email: changeme@foobar.com 15 | # CHANGEME: This is a staging server. Update to the prod server 16 | caServer: https://acme-staging-v02.api.letsencrypt.org/directory 17 | # caServer: https://acme-v02.api.letsencrypt.org/directory 18 | storage: /etc/acme/acme.json 19 | httpChallenge: 20 | entryPoint: web 21 | # CHANGEME: If you'd like to use the DNS challenge for a wildcard cert. 22 | # See https://doc.traefik.io/traefik/https/acme/#dnschallenge 23 | # dnsChallenge: 24 | # provider: TODO 25 | 26 | entryPoints: 27 | web: 28 | address: :80 29 | websecure: 30 | address: :443 31 | 32 | global: 33 | checkNewVersion: true 34 | sendAnonymousUsage: false 35 | 36 | serversTransport: 37 | insecureSkipVerify: true 38 | 39 | providers: 40 | docker: 41 | endpoint: tcp://docker-socket-proxy:2375 42 | exposedByDefault: false 43 | file: 44 | directory: /etc/traefik/dynamic-conf 45 | watch: true 46 | --------------------------------------------------------------------------------