├── .dockerignore ├── .gitattributes ├── .github └── workflows │ ├── cron.update.yml │ ├── docker.yml │ ├── errors.yml │ ├── readme.yml │ └── tags.yml ├── .gitignore ├── .json ├── LICENSE ├── README.md ├── arch.dockerfile ├── compose.yaml ├── errors.dockerfile ├── project.md └── rootfs └── errors ├── main.js └── template.default.html /.dockerignore: -------------------------------------------------------------------------------- 1 | # default 2 | .git* 3 | maintain/ 4 | LICENSE 5 | *.md 6 | img/ 7 | node_modules/ 8 | .env -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # default 2 | * text=auto 3 | *.sh eol=lf -------------------------------------------------------------------------------- /.github/workflows/cron.update.yml: -------------------------------------------------------------------------------- 1 | name: cron-update 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 5 * * *" 7 | 8 | jobs: 9 | cron-update: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | actions: read 14 | contents: write 15 | 16 | steps: 17 | - name: init / checkout 18 | uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 19 | with: 20 | ref: 'master' 21 | fetch-depth: 0 22 | 23 | - name: cron-update / get latest version 24 | run: | 25 | echo "LATEST_VERSION=$(curl -s https://api.github.com/repos/traefik/traefik/releases/latest | jq -r '.tag_name' | sed 's/v//')" >> "${GITHUB_ENV}" 26 | echo "LATEST_TAG=$(git describe --abbrev=0 --tags `git rev-list --tags --max-count=1` | sed 's/v//')" >> "${GITHUB_ENV}" 27 | 28 | - name: cron-update / setup node 29 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 30 | with: 31 | node-version: '20' 32 | - run: npm i semver 33 | 34 | - name: cron-update / compare latest with current version 35 | uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298 36 | with: 37 | script: | 38 | const { existsSync, readFileSync, writeFileSync } = require('node:fs'); 39 | const { resolve } = require('node:path'); 40 | const { inspect } = require('node:util'); 41 | const semver = require('semver') 42 | const repository = {dot:{}}; 43 | 44 | try{ 45 | const path = resolve('.json'); 46 | if(existsSync(path)){ 47 | try{ 48 | repository.dot = JSON.parse(readFileSync(path).toString()); 49 | }catch(e){ 50 | throw new Error('could not parse .json'); 51 | } 52 | }else{ 53 | throw new Error('.json does not exist'); 54 | } 55 | }catch(e){ 56 | core.setFailed(e); 57 | } 58 | 59 | const latest = semver.valid(semver.coerce('${{ env.LATEST_VERSION }}')); 60 | const current = semver.valid(semver.coerce(repository.dot.semver.version)); 61 | const tag = semver.valid(semver.coerce('${{ env.LATEST_TAG }}')); 62 | 63 | if(latest && latest !== current){ 64 | core.info(`new ${semver.diff(current, latest)} release found (${latest})!`) 65 | repository.dot.semver.version = latest; 66 | if(tag){ 67 | core.exportVariable('WORKFLOW_NEW_TAG', semver.inc(tag, semver.diff(current, latest))); 68 | } 69 | 70 | if(repository.dot.semver?.latest){ 71 | repository.dot.semver.latest = repository.dot.semver.version; 72 | } 73 | 74 | if(repository.dot?.readme?.comparison?.image){ 75 | repository.dot.readme.comparison.image = repository.dot.readme.comparison.image.replace(current, repository.dot.semver.version); 76 | } 77 | 78 | try{ 79 | writeFileSync(resolve('.json'), JSON.stringify(repository.dot, null, 2)); 80 | core.exportVariable('WORKFLOW_AUTO_UPDATE', true); 81 | }catch(e){ 82 | core.setFailed(e); 83 | } 84 | }else{ 85 | core.info('no new release found'); 86 | } 87 | 88 | core.info(inspect(repository.dot, {showHidden:false, depth:null, colors:true})); 89 | 90 | - name: cron-update / checkout 91 | id: checkout 92 | if: env.WORKFLOW_AUTO_UPDATE == 'true' 93 | run: | 94 | git config user.name "github-actions[bot]" 95 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 96 | git add .json 97 | git commit -m "[upgrade] ${{ env.LATEST_VERSION }}" 98 | git push origin HEAD:master 99 | 100 | - name: cron-update / tag 101 | if: env.WORKFLOW_AUTO_UPDATE == 'true' && steps.checkout.outcome == 'success' 102 | run: | 103 | SHA256=$(git rev-list --branches --max-count=1) 104 | git tag -a v${{ env.WORKFLOW_NEW_TAG }} -m "v${{ env.WORKFLOW_NEW_TAG }}" ${SHA256} 105 | git push --follow-tags 106 | 107 | - name: cron-update / build docker image 108 | if: env.WORKFLOW_AUTO_UPDATE == 'true' && steps.checkout.outcome == 'success' 109 | uses: the-actions-org/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7 110 | with: 111 | workflow: docker.yml 112 | wait-for-completion: false 113 | token: "${{ secrets.REPOSITORY_TOKEN }}" 114 | inputs: '{ "release":"true", "readme":"true" }' 115 | ref: "v${{ env.WORKFLOW_NEW_TAG }}" -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: docker 2 | run-name: ${{ inputs.run-name }} 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | run-name: 8 | description: 'set run-name for workflow (multiple calls)' 9 | type: string 10 | required: false 11 | default: 'docker' 12 | 13 | runs-on: 14 | description: 'set runs-on for workflow (github or selfhosted)' 15 | type: string 16 | required: false 17 | default: 'ubuntu-22.04' 18 | 19 | build: 20 | description: 'set WORKFLOW_BUILD' 21 | required: false 22 | default: 'true' 23 | 24 | release: 25 | description: 'set WORKFLOW_GITHUB_RELEASE' 26 | required: false 27 | default: 'false' 28 | 29 | readme: 30 | description: 'set WORKFLOW_GITHUB_README' 31 | required: false 32 | default: 'false' 33 | 34 | etc: 35 | description: 'base64 encoded json string' 36 | required: false 37 | 38 | jobs: 39 | docker: 40 | runs-on: ${{ inputs.runs-on }} 41 | timeout-minutes: 1440 42 | 43 | services: 44 | registry: 45 | image: registry:2 46 | ports: 47 | - 5000:5000 48 | 49 | permissions: 50 | actions: read 51 | contents: write 52 | packages: write 53 | 54 | steps: 55 | - name: init / checkout 56 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 57 | with: 58 | ref: ${{ github.ref_name }} 59 | fetch-depth: 0 60 | 61 | - name: init / setup environment 62 | uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298 63 | with: 64 | script: | 65 | const { existsSync, readFileSync } = require('node:fs'); 66 | const { resolve } = require('node:path'); 67 | const { inspect } = require('node:util'); 68 | const { Buffer } = require('node:buffer'); 69 | const inputs = `${{ toJSON(github.event.inputs) }}`; 70 | const opt = {input:{}, dot:{}}; 71 | 72 | try{ 73 | if(inputs.length > 0){ 74 | opt.input = JSON.parse(inputs); 75 | if(opt.input?.etc){ 76 | opt.input.etc = JSON.parse(Buffer.from(opt.input.etc, 'base64').toString('ascii')); 77 | } 78 | } 79 | }catch(e){ 80 | core.warning('could not parse github.event.inputs'); 81 | } 82 | 83 | try{ 84 | const path = resolve('.json'); 85 | if(existsSync(path)){ 86 | try{ 87 | opt.dot = JSON.parse(readFileSync(path).toString()); 88 | }catch(e){ 89 | throw new Error('could not parse .json'); 90 | } 91 | }else{ 92 | throw new Error('.json does not exist'); 93 | } 94 | }catch(e){ 95 | core.setFailed(e); 96 | } 97 | 98 | core.info(inspect(opt, {showHidden:false, depth:null, colors:true})); 99 | 100 | const docker = { 101 | image:{ 102 | name:opt.dot.image, 103 | arch:(opt.dot.arch || 'linux/amd64,linux/arm64'), 104 | prefix:((opt.input?.etc?.semverprefix) ? `${opt.input?.etc?.semverprefix}-` : ''), 105 | suffix:((opt.input?.etc?.semversuffix) ? `-${opt.input?.etc?.semversuffix}` : ''), 106 | description:(opt.dot?.readme?.description || ''), 107 | tags:[], 108 | }, 109 | app:{ 110 | image:opt.dot.image, 111 | name:opt.dot.name, 112 | version:(opt.input?.etc?.version || opt.dot?.semver?.version), 113 | root:opt.dot.root, 114 | UID:(opt.input?.etc?.uid || 1000), 115 | GID:(opt.input?.etc?.gid || 1000), 116 | no_cache:new Date().getTime(), 117 | }, 118 | cache:{ 119 | registry:'localhost:5000/', 120 | }, 121 | tags:[], 122 | }; 123 | 124 | docker.cache.name = `${docker.image.name}:${docker.image.prefix}buildcache${docker.image.suffix}`; 125 | docker.cache.grype = `${docker.cache.registry}${docker.image.name}:${docker.image.prefix}grype${docker.image.suffix}`; 126 | docker.app.prefix = docker.image.prefix; 127 | docker.app.suffix = docker.image.suffix; 128 | 129 | // setup tags 130 | if(!opt.dot?.semver?.disable?.rolling){ 131 | docker.image.tags.push('rolling'); 132 | } 133 | if(opt.input?.etc?.dockerfile !== 'arch.dockerfile' && opt.input?.etc?.tag){ 134 | docker.image.tags.push(`${context.sha.substring(0,7)}`); 135 | docker.image.tags.push(opt.input.etc.tag); 136 | docker.image.tags.push(`${opt.input.etc.tag}-${docker.app.version}`); 137 | docker.cache.name = `${docker.image.name}:buildcache-${opt.input.etc.tag}`; 138 | }else if(docker.app.version !== 'latest'){ 139 | const semver = docker.app.version.split('.'); 140 | docker.image.tags.push(`${context.sha.substring(0,7)}`); 141 | if(Array.isArray(semver)){ 142 | if(semver.length >= 1) docker.image.tags.push(`${semver[0]}`); 143 | if(semver.length >= 2) docker.image.tags.push(`${semver[0]}.${semver[1]}`); 144 | if(semver.length >= 3) docker.image.tags.push(`${semver[0]}.${semver[1]}.${semver[2]}`); 145 | } 146 | if(opt.dot?.semver?.stable && new RegExp(opt.dot?.semver.stable, 'ig').test(docker.image.tags.join(','))) docker.image.tags.push('stable'); 147 | if(opt.dot?.semver?.latest && new RegExp(opt.dot?.semver.latest, 'ig').test(docker.image.tags.join(','))) docker.image.tags.push('latest'); 148 | }else{ 149 | docker.image.tags.push('latest'); 150 | } 151 | 152 | for(const tag of docker.image.tags){ 153 | docker.tags.push(`${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}`); 154 | docker.tags.push(`ghcr.io/${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}`); 155 | docker.tags.push(`quay.io/${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}`); 156 | } 157 | 158 | // setup build arguments 159 | if(opt.input?.etc?.build?.args){ 160 | for(const arg in opt.input.etc.build.args){ 161 | docker.app[arg] = opt.input.etc.build.args[arg]; 162 | } 163 | } 164 | if(opt.dot?.build?.args){ 165 | for(const arg in opt.dot.build.args){ 166 | docker.app[arg] = opt.dot.build.args[arg]; 167 | } 168 | } 169 | const arguments = []; 170 | for(const argument in docker.app){ 171 | arguments.push(`APP_${argument.toUpperCase()}=${docker.app[argument]}`); 172 | } 173 | 174 | // export to environment 175 | core.exportVariable('DOCKER_CACHE_REGISTRY', docker.cache.registry); 176 | core.exportVariable('DOCKER_CACHE_NAME', docker.cache.name); 177 | core.exportVariable('DOCKER_CACHE_GRYPE', docker.cache.grype); 178 | 179 | core.exportVariable('DOCKER_IMAGE_NAME', docker.image.name); 180 | core.exportVariable('DOCKER_IMAGE_ARCH', docker.image.arch); 181 | core.exportVariable('DOCKER_IMAGE_TAGS', docker.tags.join(',')); 182 | core.exportVariable('DOCKER_IMAGE_DESCRIPTION', docker.image.description); 183 | core.exportVariable('DOCKER_IMAGE_ARGUMENTS', arguments.join("\r\n")); 184 | core.exportVariable('DOCKER_IMAGE_DOCKERFILE', opt.input?.etc?.dockerfile || 'arch.dockerfile'); 185 | 186 | core.exportVariable('WORKFLOW_BUILD', (opt.input?.build === undefined) ? false : opt.input.build); 187 | core.exportVariable('WORKFLOW_CREATE_RELEASE', (opt.input?.release === undefined) ? false : opt.input.release); 188 | core.exportVariable('WORKFLOW_CREATE_README', (opt.input?.readme === undefined) ? false : opt.input.readme); 189 | core.exportVariable('WORKFLOW_GRYPE_FAIL_ON_SEVERITY', (opt.dot?.grype?.fail === undefined) ? true : opt.dot.grype.fail); 190 | core.exportVariable('WORKFLOW_GRYPE_SEVERITY_CUTOFF', (opt.dot?.grype?.severity || 'high')); 191 | if(opt.dot?.readme?.comparison){ 192 | core.exportVariable('WORKFLOW_CREATE_COMPARISON', true); 193 | core.exportVariable('WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE', opt.dot.readme.comparison.image); 194 | core.exportVariable('WORKFLOW_CREATE_COMPARISON_IMAGE', `${docker.image.name}:${docker.app.version}`); 195 | } 196 | 197 | 198 | 199 | # DOCKER 200 | - name: docker / login to hub 201 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 202 | with: 203 | username: 11notes 204 | password: ${{ secrets.DOCKER_TOKEN }} 205 | 206 | - name: github / login to ghcr 207 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 208 | with: 209 | registry: ghcr.io 210 | username: 11notes 211 | password: ${{ secrets.GITHUB_TOKEN }} 212 | 213 | - name: quay / login to quay 214 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 215 | with: 216 | registry: quay.io 217 | username: 11notes+github 218 | password: ${{ secrets.QUAY_TOKEN }} 219 | 220 | - name: docker / setup qemu 221 | if: env.WORKFLOW_BUILD == 'true' 222 | uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a 223 | 224 | - name: docker / setup buildx 225 | if: env.WORKFLOW_BUILD == 'true' 226 | uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 227 | with: 228 | driver-opts: network=host 229 | 230 | - name: docker / build & push & tag grype 231 | if: env.WORKFLOW_BUILD == 'true' 232 | id: docker-build 233 | uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d 234 | with: 235 | context: . 236 | file: ${{ env.DOCKER_IMAGE_DOCKERFILE }} 237 | push: true 238 | platforms: ${{ env.DOCKER_IMAGE_ARCH }} 239 | cache-from: type=registry,ref=${{ env.DOCKER_CACHE_NAME }} 240 | cache-to: type=registry,ref=${{ env.DOCKER_CACHE_REGISTRY }}${{ env.DOCKER_CACHE_NAME }},mode=max,compression=zstd,force-compression=true 241 | build-args: | 242 | ${{ env.DOCKER_IMAGE_ARGUMENTS }} 243 | tags: | 244 | ${{ env.DOCKER_CACHE_GRYPE }} 245 | 246 | - name: grype / scan 247 | if: env.WORKFLOW_BUILD == 'true' 248 | id: grype 249 | uses: anchore/scan-action@dc6246fcaf83ae86fcc6010b9824c30d7320729e 250 | with: 251 | image: ${{ env.DOCKER_CACHE_GRYPE }} 252 | fail-build: ${{ env.WORKFLOW_GRYPE_FAIL_ON_SEVERITY }} 253 | severity-cutoff: ${{ env.WORKFLOW_GRYPE_SEVERITY_CUTOFF }} 254 | output-format: 'sarif' 255 | by-cve: true 256 | cache-db: true 257 | 258 | - name: grype / fail 259 | if: env.WORKFLOW_BUILD == 'true' && (failure() || steps.grype.outcome == 'failure') 260 | uses: anchore/scan-action@dc6246fcaf83ae86fcc6010b9824c30d7320729e 261 | with: 262 | image: ${{ env.DOCKER_CACHE_GRYPE }} 263 | fail-build: false 264 | severity-cutoff: ${{ env.WORKFLOW_GRYPE_SEVERITY_CUTOFF }} 265 | output-format: 'table' 266 | by-cve: true 267 | cache-db: true 268 | 269 | - name: docker / build & push 270 | if: env.WORKFLOW_BUILD == 'true' 271 | uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d 272 | with: 273 | context: . 274 | file: ${{ env.DOCKER_IMAGE_DOCKERFILE }} 275 | push: true 276 | sbom: true 277 | provenance: mode=max 278 | platforms: ${{ env.DOCKER_IMAGE_ARCH }} 279 | cache-from: type=registry,ref=${{ env.DOCKER_CACHE_REGISTRY }}${{ env.DOCKER_CACHE_NAME }} 280 | cache-to: type=registry,ref=${{ env.DOCKER_CACHE_NAME }},mode=max,compression=zstd,force-compression=true 281 | build-args: | 282 | ${{ env.DOCKER_IMAGE_ARGUMENTS }} 283 | tags: | 284 | ${{ env.DOCKER_IMAGE_TAGS }} 285 | 286 | 287 | 288 | # RELEASE 289 | - name: github / release / log 290 | continue-on-error: true 291 | id: git-log 292 | run: | 293 | LOCAL_LAST_TAG=$(git describe --abbrev=0 --tags `git rev-list --tags --skip=1 --max-count=1`) 294 | echo "using last tag: ${LOCAL_LAST_TAG}" 295 | LOCAL_COMMITS=$(git log ${LOCAL_LAST_TAG}..HEAD --oneline) 296 | 297 | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) 298 | echo "commits<<${EOF}" >> ${GITHUB_OUTPUT} 299 | echo "${LOCAL_COMMITS}" >> ${GITHUB_OUTPUT} 300 | echo "${EOF}" >> ${GITHUB_OUTPUT} 301 | 302 | - name: github / release / markdown 303 | if: env.WORKFLOW_CREATE_RELEASE == 'true' && steps.git-log.outcome == 'success' 304 | id: git-release 305 | uses: 11notes/action-docker-release@v1 306 | # WHY IS THIS ACTION NOT SHA256 PINNED? SECURITY MUCH?!?!?! 307 | # --------------------------------------------------------------------------------- 308 | # the next step "github / release / create" creates a new release based on the code 309 | # in the repo. This code is not modified and can't be modified by this action. 310 | # It does create the markdown for the release, which could be abused, but to what 311 | # extend? Adding a link to a malicious repo? 312 | with: 313 | git_log: ${{ steps.git-log.outputs.commits }} 314 | 315 | - name: github / release / create 316 | if: env.WORKFLOW_CREATE_RELEASE == 'true' && steps.git-release.outcome == 'success' 317 | uses: actions/create-release@4c11c9fe1dcd9636620a16455165783b20fc7ea0 318 | env: 319 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 320 | with: 321 | tag_name: ${{ github.ref }} 322 | release_name: ${{ github.ref }} 323 | body: ${{ steps.git-release.outputs.release }} 324 | draft: false 325 | prerelease: false 326 | 327 | 328 | 329 | 330 | # LICENSE 331 | - name: license / update year 332 | continue-on-error: true 333 | uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298 334 | with: 335 | script: | 336 | const { existsSync, readFileSync, writeFileSync } = require('node:fs'); 337 | const { resolve } = require('node:path'); 338 | const file = 'LICENSE'; 339 | const year = new Date().getFullYear(); 340 | try{ 341 | const path = resolve(file); 342 | if(existsSync(path)){ 343 | let license = readFileSync(file).toString(); 344 | if(!new RegExp(`Copyright \\(c\\) ${year} 11notes`, 'i').test(license)){ 345 | license = license.replace(/Copyright \(c\) \d{4} /i, `Copyright (c) ${new Date().getFullYear()} `); 346 | writeFileSync(path, license); 347 | } 348 | }else{ 349 | throw new Error(`file ${file} does not exist`); 350 | } 351 | }catch(e){ 352 | core.setFailed(e); 353 | } 354 | 355 | 356 | 357 | 358 | # README 359 | - name: github / checkout HEAD 360 | continue-on-error: true 361 | run: | 362 | git checkout HEAD 363 | 364 | - name: docker / setup comparison images 365 | if: env.WORKFLOW_CREATE_COMPARISON == 'true' 366 | continue-on-error: true 367 | run: | 368 | docker image pull ${{ env.WORKFLOW_CREATE_COMPARISON_IMAGE }} 369 | docker image ls --filter "reference=${{ env.WORKFLOW_CREATE_COMPARISON_IMAGE }}" --format json | jq --raw-output '.Size' &> ./comparison.size0.log 370 | 371 | docker image pull ${{ env.WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE }} 372 | docker image ls --filter "reference=${{ env.WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE }}" --format json | jq --raw-output '.Size' &> ./comparison.size1.log 373 | 374 | docker run --entrypoint "/bin/sh" --rm ${{ env.WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE }} -c id &> ./comparison.id.log 375 | 376 | - name: github / create README.md 377 | id: github-readme 378 | continue-on-error: true 379 | if: env.WORKFLOW_CREATE_README == 'true' 380 | uses: 11notes/action-docker-readme@v1 381 | # WHY IS THIS ACTION NOT SHA256 PINNED? SECURITY MUCH?!?!?! 382 | # --------------------------------------------------------------------------------- 383 | # the next step "github / commit & push" only adds the README and LICENSE as well as 384 | # compose.yaml to the repository. This does not pose a security risk if this action 385 | # would be compromised. The code of the app can't be changed by this action. Since 386 | # only the files mentioned are commited to the repo. Sure, someone could make a bad 387 | # compose.yaml, but since this serves only as an example I see no harm in that. 388 | with: 389 | sarif_file: ${{ steps.grype.outputs.sarif }} 390 | build_output_metadata: ${{ steps.docker-build.outputs.metadata }} 391 | 392 | - name: docker / push README.md to docker hub 393 | continue-on-error: true 394 | if: steps.github-readme.outcome == 'success' && hashFiles('README_NONGITHUB.md') != '' 395 | uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8 396 | env: 397 | DOCKER_USER: 11notes 398 | DOCKER_PASS: ${{ secrets.DOCKER_TOKEN }} 399 | with: 400 | destination_container_repo: ${{ env.DOCKER_IMAGE_NAME }} 401 | provider: dockerhub 402 | short_description: ${{ env.DOCKER_IMAGE_DESCRIPTION }} 403 | readme_file: 'README_NONGITHUB.md' 404 | 405 | - name: github / commit & push 406 | continue-on-error: true 407 | if: steps.github-readme.outcome == 'success' && hashFiles('README.md') != '' 408 | run: | 409 | git config user.name "github-actions[bot]" 410 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 411 | git add README.md 412 | if [ -f compose.yaml ]; then 413 | git add compose.yaml 414 | fi 415 | if [ -f LICENSE ]; then 416 | git add LICENSE 417 | fi 418 | git commit -m "github-actions[bot]: update README.md" 419 | git push origin HEAD:master 420 | 421 | 422 | 423 | 424 | # REPOSITORY SETTINGS 425 | - name: github / update description and set repo defaults 426 | run: | 427 | curl --request PATCH \ 428 | --url https://api.github.com/repos/${{ github.repository }} \ 429 | --header 'authorization: Bearer ${{ secrets.REPOSITORY_TOKEN }}' \ 430 | --header 'content-type: application/json' \ 431 | --data '{ 432 | "description":"${{ env.DOCKER_IMAGE_DESCRIPTION }}", 433 | "homepage":"", 434 | "has_issues":true, 435 | "has_discussions":true, 436 | "has_projects":false, 437 | "has_wiki":false 438 | }' \ 439 | --fail -------------------------------------------------------------------------------- /.github/workflows/errors.yml: -------------------------------------------------------------------------------- 1 | name: tags 2 | on: 3 | push: 4 | tags: 5 | - 'errors' 6 | jobs: 7 | errors: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: init / base64 nested json 11 | uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298 12 | with: 13 | script: | 14 | const { Buffer } = require('node:buffer'); 15 | const etc = { 16 | dockerfile:"errors.dockerfile", 17 | tag:"errors", 18 | version:"stable", 19 | }; 20 | core.exportVariable('WORKFLOW_BASE64JSON', Buffer.from(JSON.stringify(etc)).toString('base64')); 21 | 22 | - name: build docker image 23 | uses: the-actions-org/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7 24 | with: 25 | workflow: docker.yml 26 | token: "${{ secrets.REPOSITORY_TOKEN }}" 27 | inputs: '{ "release":"false", "readme":"false", "run-name":"errors", "etc":"${{ env.WORKFLOW_BASE64JSON }}" }' -------------------------------------------------------------------------------- /.github/workflows/readme.yml: -------------------------------------------------------------------------------- 1 | name: readme 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | readme: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: update README.md 11 | uses: the-actions-org/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7 12 | with: 13 | wait-for-completion: false 14 | workflow: docker.yml 15 | token: "${{ secrets.REPOSITORY_TOKEN }}" 16 | inputs: '{ "build":"false", "release":"false", "readme":"true" }' -------------------------------------------------------------------------------- /.github/workflows/tags.yml: -------------------------------------------------------------------------------- 1 | name: tags 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | jobs: 7 | docker: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: build docker image 11 | uses: the-actions-org/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7 12 | with: 13 | workflow: docker.yml 14 | wait-for-completion: false 15 | token: "${{ secrets.REPOSITORY_TOKEN }}" 16 | inputs: '{ "release":"true", "readme":"true" }' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # default 2 | maintain/ 3 | node_modules/ 4 | .env -------------------------------------------------------------------------------- /.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "11notes/traefik", 3 | "name": "traefik", 4 | "root": "/traefik", 5 | "arch": "linux/amd64,linux/arm64,linux/arm/v7", 6 | "semver": { 7 | "version": "3.4.1" 8 | }, 9 | "readme": { 10 | "description": "Run traefik rootless, distroless and secure by default!", 11 | "built": { 12 | "traefik": "https://github.com/traefik/traefik" 13 | }, 14 | "distroless": { 15 | "layers": [ 16 | "11notes/distroless", 17 | "11notes/distroless:curl" 18 | ] 19 | }, 20 | "comparison": { 21 | "image": "traefik:3.4.1" 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 11notes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![banner](https://github.com/11notes/defaults/blob/main/static/img/banner.png?raw=true) 2 | 3 | # TRAEFIK 4 | [](https://github.com/11notes/docker-TRAEFIK)![5px](https://github.com/11notes/defaults/blob/main/static/img/transparent5x2px.png?raw=true)![size](https://img.shields.io/docker/image-size/11notes/traefik/3.4.1?color=0eb305)![5px](https://github.com/11notes/defaults/blob/main/static/img/transparent5x2px.png?raw=true)![version](https://img.shields.io/docker/v/11notes/traefik/3.4.1?color=eb7a09)![5px](https://github.com/11notes/defaults/blob/main/static/img/transparent5x2px.png?raw=true)![pulls](https://img.shields.io/docker/pulls/11notes/traefik?color=2b75d6)![5px](https://github.com/11notes/defaults/blob/main/static/img/transparent5x2px.png?raw=true)[](https://github.com/11notes/docker-TRAEFIK/issues)![5px](https://github.com/11notes/defaults/blob/main/static/img/transparent5x2px.png?raw=true)![swiss_made](https://img.shields.io/badge/Swiss_Made-FFFFFF?labelColor=FF0000&logo=data:image/svg%2bxml;base64,PHN2ZyB2ZXJzaW9uPSIxIiB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDMyIDMyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0wIDBoMzJ2MzJoLTMyeiIgZmlsbD0iI2YwMCIvPjxwYXRoIGQ9Im0xMyA2aDZ2N2g3djZoLTd2N2gtNnYtN2gtN3YtNmg3eiIgZmlsbD0iI2ZmZiIvPjwvc3ZnPg==) 5 | 6 | Run traefik rootless, distroless and secure by default! 7 | 8 | # SYNOPSIS 📖 9 | **What can I do with this?** Run the prefer IaC reverse proxy distroless and rootless for maximum security. 10 | 11 | # UNIQUE VALUE PROPOSITION 💶 12 | **Why should I run this image and not the other image(s) that already exist?** Good question! All the other images on the market that do exactly the same don’t do or offer these options: 13 | 14 | > [!IMPORTANT] 15 | >* This image runs as 1000:1000 by default, most other images run everything as root 16 | >* This image has no shell since it is 100% distroless, most other images run on a distro like Debian or Alpine with full shell access (security) 17 | >* This image is created via a secure, pinned CI/CD process and immune to upstream attacks, most other images have upstream dependencies that can be exploited 18 | >* This image contains a proper health check that verifies the app is actually working, most other images have either no health check or only check if a port is open or ping works 19 | >* This image works as read-only, most other images need to write files to the image filesystem 20 | >* This image is a lot smaller than most other images 21 | 22 | If you value security, simplicity and the ability to interact with the maintainer and developer of an image. Using my images is a great start in that direction. 23 | 24 | # COMPARISON 🏁 25 | Below you find a comparison between this image and the most used or original one. 26 | 27 | | **image** | 11notes/traefik:3.4.1 | traefik:3.4.1 | 28 | | ---: | :---: | :---: | 29 | | **image size on disk** | 55.6MB | 224MB | 30 | | **process UID/GID** | 1000/1000 | 0/0 | 31 | | **distroless?** | ✅ | ❌ | 32 | | **rootless?** | ✅ | ❌ | 33 | 34 | 35 | # VOLUMES 📁 36 | * **/traefik/var** - Directory of all dynamic data and configurations 37 | 38 | # COMPOSE ✂️ 39 | ```yaml 40 | name: "reverse-proxy" 41 | services: 42 | socket-proxy: 43 | # this image is used to expose the docker socket as read-only to traefik 44 | # you can check https://github.com/11notes/docker-socket-proxy for all details 45 | image: "11notes/socket-proxy:2.1.2" 46 | read_only: true 47 | user: "0:0" 48 | environment: 49 | TZ: "Europe/Zurich" 50 | volumes: 51 | - "/run/docker.sock:/run/docker.sock:ro" 52 | - "socket-proxy.run:/run/proxy" 53 | restart: "always" 54 | 55 | errors: 56 | # this image can be used to display a simple error message since Traefik can’t serve content 57 | image: "11notes/traefik:errors" 58 | read_only: true 59 | labels: 60 | - "traefik.enable=true" 61 | - "traefik.http.services.default-errors.loadbalancer.server.port=8080" 62 | environment: 63 | TZ: "Europe/Zurich" 64 | networks: 65 | backend: 66 | restart: "always" 67 | 68 | traefik: 69 | image: "11notes/traefik:3.4.1" 70 | read_only: true 71 | labels: 72 | - "traefik.enable=true" 73 | 74 | # example on how to secure the traefik dashboard and api 75 | - "traefik.http.routers.dashboard.rule=Host(`${TRAEFIK_FQDN}`)" 76 | - "traefik.http.routers.dashboard.service=api@internal" 77 | - "traefik.http.routers.dashboard.middlewares=dashboard-auth" 78 | - "traefik.http.routers.dashboard.entrypoints=https" 79 | - "traefik.http.routers.dashboard.tls=true" 80 | - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$2a$12$ktgZsFQZ0S1FeQbI1JjS9u36fAJMHDQaY6LNi9EkEp8sKtP5BK43C" # admin / traefik, please change! 81 | 82 | # default ratelimit 83 | - "traefik.http.middlewares.default-ratelimit.ratelimit.average=100" 84 | - "traefik.http.middlewares.default-ratelimit.ratelimit.burst=120" 85 | - "traefik.http.middlewares.default-ratelimit.ratelimit.period=1s" 86 | 87 | # default allowlist 88 | - "traefik.http.middlewares.default-ipallowlist-RFC1918.ipallowlist.sourcerange=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" 89 | 90 | # default catch-all router 91 | - "traefik.http.routers.default.rule=HostRegexp(`.+`)" 92 | - "traefik.http.routers.default.priority=1" 93 | - "traefik.http.routers.default.entrypoints=https" 94 | - "traefik.http.routers.default.tls=true" 95 | - "traefik.http.routers.default.service=default-errors" 96 | 97 | # default http to https 98 | # if you need a http website, don't worry, this router has priority 1 99 | - "traefik.http.middlewares.default-http.redirectscheme.permanent=true" 100 | - "traefik.http.middlewares.default-http.redirectscheme.scheme=https" 101 | - "traefik.http.routers.default-http.priority=1" 102 | - "traefik.http.routers.default-http.rule=HostRegexp(`.+`)" 103 | - "traefik.http.routers.default-http.entrypoints=http" 104 | - "traefik.http.routers.default-http.middlewares=default-http" 105 | - "traefik.http.routers.default-http.service=default-http" 106 | - "traefik.http.services.default-http.loadbalancer.passhostheader=true" 107 | 108 | # default errors 109 | - "traefik.http.middlewares.default-errors.errors.status=402-599" 110 | - "traefik.http.middlewares.default-errors.errors.query=/{status}" 111 | - "traefik.http.middlewares.default-errors.errors.service=default-errors" 112 | depends_on: 113 | socket-proxy: 114 | condition: "service_healthy" 115 | restart: true 116 | environment: 117 | TZ: "Europe/Zurich" 118 | command: 119 | - "--ping.terminatingStatusCode=204" # ping is needed for the health check to work! 120 | - "--global.checkNewVersion=false" 121 | - "--global.sendAnonymousUsage=false" 122 | - "--accesslog=true" 123 | - "--api.dashboard=true" 124 | - "--api.insecure=false" # disable insecure api and dashboard access 125 | - "--log.level=INFO" 126 | - "--log.format=json" 127 | - "--providers.docker.exposedByDefault=false" 128 | - "--providers.file.directory=/traefik/var" 129 | - "--entrypoints.http.address=:80" 130 | - "--entrypoints.http.http.middlewares=default-errors,default-ratelimit,default-ipallowlist-RFC1918" 131 | - "--entrypoints.https.address=:443" 132 | - "--entrypoints.https.http.middlewares=default-errors,default-ratelimit,default-ipallowlist-RFC1918" 133 | - "--serversTransport.insecureSkipVerify=true" # disable upstream HTTPS certificate checks (https > https) 134 | - "--experimental.plugins.rewriteResponseHeaders.moduleName=github.com/jamesmcroft/traefik-plugin-rewrite-response-headers" 135 | - "--experimental.plugins.rewriteResponseHeaders.version=v1.1.2" 136 | - "--experimental.plugins.geoblock.moduleName=github.com/PascalMinder/geoblock" 137 | - "--experimental.plugins.geoblock.version=v0.3.2" 138 | ports: 139 | - "80:80/tcp" 140 | - "443:443/tcp" 141 | volumes: 142 | - "var:/traefik/var" 143 | - "socket-proxy.run:/var/run" # access docker socket via proxy read-only 144 | - "plugins:/plugins-storage" # plugins stored as volume because of read-only 145 | networks: 146 | backend: 147 | frontend: 148 | sysctls: 149 | net.ipv4.ip_unprivileged_port_start: 80 # allow rootless container to access port 80 and higher 150 | restart: "always" 151 | 152 | nginx: # example container 153 | image: "11notes/nginx:stable" 154 | labels: 155 | - "traefik.enable=true" 156 | - "traefik.http.routers.nginx-example.rule=Host(`${NGINX_FQDN}`)" 157 | - "traefik.http.routers.nginx-example.entrypoints=https" 158 | - "traefik.http.routers.nginx-example.tls=true" 159 | - "traefik.http.routers.nginx-example.service=nginx-example" 160 | - "traefik.http.services.nginx-example.loadbalancer.server.port=3000" 161 | networks: 162 | backend: 163 | restart: "always" 164 | 165 | volumes: 166 | var: 167 | plugins: 168 | socket-proxy.run: 169 | 170 | networks: 171 | frontend: 172 | backend: 173 | internal: true 174 | ``` 175 | 176 | # DEFAULT SETTINGS 🗃️ 177 | | Parameter | Value | Description | 178 | | --- | --- | --- | 179 | | `user` | docker | user name | 180 | | `uid` | 1000 | [user identifier](https://en.wikipedia.org/wiki/User_identifier) | 181 | | `gid` | 1000 | [group identifier](https://en.wikipedia.org/wiki/Group_identifier) | 182 | | `home` | /traefik | home directory of user docker | 183 | | `login` | admin // traefik | login using compose example | 184 | 185 | # ENVIRONMENT 📝 186 | | Parameter | Value | Default | 187 | | --- | --- | --- | 188 | | `TZ` | [Time Zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) | | 189 | | `DEBUG` | Will activate debug option for container image and app (if available) | | 190 | 191 | # MAIN TAGS 🏷️ 192 | These are the main tags for the image. There is also a tag for each commit and its shorthand sha256 value. 193 | 194 | * [3.4.1](https://hub.docker.com/r/11notes/traefik/tags?name=3.4.1) 195 | 196 | ### There is no latest tag, what am I supposed to do about updates? 197 | It is of my opinion that the ```:latest``` tag is super dangerous. Many times, I’ve introduced **breaking** changes to my images. This would have messed up everything for some people. If you don’t want to change the tag to the latest [semver](https://semver.org/), simply use the short versions of [semver](https://semver.org/). Instead of using ```:3.4.1``` you can use ```:3``` or ```:3.4```. Since on each new version these tags are updated to the latest version of the software, using them is identical to using ```:latest``` but at least fixed to a major or minor version. 198 | 199 | If you still insist on having the bleeding edge release of this app, simply use the ```:rolling``` tag, but be warned! You will get the latest version of the app instantly, regardless of breaking changes or security issues or what so ever. You do this at your own risk! 200 | 201 | # REGISTRIES ☁️ 202 | ``` 203 | docker pull 11notes/traefik:3.4.1 204 | docker pull ghcr.io/11notes/traefik:3.4.1 205 | docker pull quay.io/11notes/traefik:3.4.1 206 | ``` 207 | 208 | # SOURCE 💾 209 | * [11notes/traefik](https://github.com/11notes/docker-TRAEFIK) 210 | 211 | # PARENT IMAGE 🏛️ 212 | > [!IMPORTANT] 213 | >This image is not based on another image but uses [scratch](https://hub.docker.com/_/scratch) as the starting layer. 214 | >The image consists of the following distroless layers that were added: 215 | >* [11notes/distroless](https://github.com/11notes/docker-distroless/blob/master/arch.dockerfile) - contains users, timezones and Root CA certificates 216 | >* [11notes/distroless:curl](https://github.com/11notes/docker-distroless/blob/master/curl.dockerfile) - app to execute HTTP or UNIX requests 217 | 218 | # BUILT WITH 🧰 219 | * [traefik](https://github.com/traefik/traefik) 220 | 221 | # GENERAL TIPS 📌 222 | > [!TIP] 223 | >* Use a reverse proxy like Traefik, Nginx, HAproxy to terminate TLS and to protect your endpoints 224 | >* Use Let’s Encrypt DNS-01 challenge to obtain valid SSL certificates for your services 225 | 226 | # CAUTION ⚠️ 227 | > [!CAUTION] 228 | >* If you use the compose example as a base for your individual configuration, please make sure to change the default dashboard login account password 229 | 230 | # ElevenNotes™️ 231 | This image is provided to you at your own risk. Always make backups before updating an image to a different version. Check the [releases](https://github.com/11notes/docker-traefik/releases) for breaking changes. If you have any problems with using this image simply raise an [issue](https://github.com/11notes/docker-traefik/issues), thanks. If you have a question or inputs please create a new [discussion](https://github.com/11notes/docker-traefik/discussions) instead of an issue. You can find all my other repositories on [github](https://github.com/11notes?tab=repositories). 232 | 233 | *created 28.05.2025, 07:18:55 (CET)* -------------------------------------------------------------------------------- /arch.dockerfile: -------------------------------------------------------------------------------- 1 | ARG APP_UID=1000 2 | ARG APP_GID=1000 3 | 4 | # :: Util 5 | FROM 11notes/util AS util 6 | 7 | # :: Build / traefik 8 | FROM alpine AS build 9 | ARG TARGETARCH 10 | ARG TARGETPLATFORM 11 | ARG TARGETVARIANT 12 | ARG APP_VERSION 13 | ENV BUILD_BIN=/traefik 14 | 15 | USER root 16 | 17 | COPY --from=util /usr/local/bin/ /usr/local/bin 18 | 19 | RUN set -ex; \ 20 | apk --update --no-cache add \ 21 | wget \ 22 | tar \ 23 | build-base \ 24 | upx; 25 | 26 | RUN set -ex; \ 27 | mkdir -p /distroless/usr/local/bin; \ 28 | wget -O traefik.tar.gz "https://github.com/traefik/traefik/releases/download/v${APP_VERSION}/traefik_v${APP_VERSION}_linux_${TARGETARCH}${TARGETVARIANT}.tar.gz"; \ 29 | tar -xzvf traefik.tar.gz; \ 30 | eleven strip ${BUILD_BIN}; \ 31 | cp ${BUILD_BIN} /distroless/usr/local/bin; 32 | 33 | # :: Distroless / traefik 34 | FROM scratch AS distroless-traefik 35 | ARG APP_ROOT 36 | COPY --from=build /distroless/ / 37 | 38 | 39 | # :: Build / file system 40 | FROM alpine AS fs 41 | ARG APP_ROOT 42 | USER root 43 | 44 | RUN set -ex; \ 45 | # volume to store certificates and dynamic yml/tml/etc 46 | mkdir -p ${APP_ROOT}/var; \ 47 | # path to store plugins as a volume if plugins are used [optional] 48 | mkdir -p /distroless/plugins-storage; 49 | 50 | # :: Distroless / file system 51 | FROM scratch AS distroless-fs 52 | ARG APP_ROOT 53 | COPY --from=fs ${APP_ROOT} /${APP_ROOT} 54 | COPY --from=fs /distroless/ / 55 | 56 | 57 | # :: Header 58 | FROM 11notes/distroless AS distroless 59 | FROM 11notes/distroless:curl AS distroless-curl 60 | FROM scratch 61 | 62 | # :: arguments 63 | ARG TARGETARCH 64 | ARG APP_IMAGE 65 | ARG APP_NAME 66 | ARG APP_VERSION 67 | ARG APP_ROOT 68 | ARG APP_UID 69 | ARG APP_GID 70 | 71 | # :: environment 72 | ENV APP_IMAGE=${APP_IMAGE} 73 | ENV APP_NAME=${APP_NAME} 74 | ENV APP_VERSION=${APP_VERSION} 75 | ENV APP_ROOT=${APP_ROOT} 76 | 77 | # :: multi-stage 78 | COPY --from=distroless --chown=${APP_UID}:${APP_GID} / / 79 | COPY --from=distroless-fs --chown=${APP_UID}:${APP_GID} / / 80 | COPY --from=distroless-curl --chown=${APP_UID}:${APP_GID} / / 81 | COPY --from=distroless-traefik --chown=${APP_UID}:${APP_GID} / / 82 | 83 | # :: Volumes 84 | VOLUME ["${APP_ROOT}/var"] 85 | 86 | # :: Monitor 87 | HEALTHCHECK --interval=5s --timeout=2s --start-period=5s \ 88 | CMD ["/usr/local/bin/curl", "-kILs", "--fail", "-o", "/dev/null", "http://localhost:8080/ping"] 89 | 90 | # :: Start 91 | USER ${APP_UID}:${APP_GID} 92 | ENTRYPOINT ["/usr/local/bin/traefik"] -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | name: "reverse-proxy" 2 | services: 3 | socket-proxy: 4 | # this image is used to expose the docker socket as read-only to traefik 5 | # you can check https://github.com/11notes/docker-socket-proxy for all details 6 | image: "11notes/socket-proxy:2.1.2" 7 | read_only: true 8 | user: "0:0" 9 | environment: 10 | TZ: "Europe/Zurich" 11 | volumes: 12 | - "/run/docker.sock:/run/docker.sock:ro" 13 | - "socket-proxy.run:/run/proxy" 14 | restart: "always" 15 | 16 | errors: 17 | # this image can be used to display a simple error message since Traefik can’t serve content 18 | image: "11notes/traefik:errors" 19 | read_only: true 20 | labels: 21 | - "traefik.enable=true" 22 | - "traefik.http.services.default-errors.loadbalancer.server.port=8080" 23 | environment: 24 | TZ: "Europe/Zurich" 25 | networks: 26 | backend: 27 | restart: "always" 28 | 29 | traefik: 30 | image: "11notes/traefik:3.4.1" 31 | read_only: true 32 | labels: 33 | - "traefik.enable=true" 34 | 35 | # example on how to secure the traefik dashboard and api 36 | - "traefik.http.routers.dashboard.rule=Host(`${TRAEFIK_FQDN}`)" 37 | - "traefik.http.routers.dashboard.service=api@internal" 38 | - "traefik.http.routers.dashboard.middlewares=dashboard-auth" 39 | - "traefik.http.routers.dashboard.entrypoints=https" 40 | - "traefik.http.routers.dashboard.tls=true" 41 | - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2a$$12$$ktgZsFQZ0S1FeQbI1JjS9u36fAJMHDQaY6LNi9EkEp8sKtP5BK43C" # admin / traefik, please change! 42 | 43 | # default ratelimit 44 | - "traefik.http.middlewares.default-ratelimit.ratelimit.average=100" 45 | - "traefik.http.middlewares.default-ratelimit.ratelimit.burst=120" 46 | - "traefik.http.middlewares.default-ratelimit.ratelimit.period=1s" 47 | 48 | # default allowlist 49 | - "traefik.http.middlewares.default-ipallowlist-RFC1918.ipallowlist.sourcerange=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" 50 | 51 | # default catch-all router 52 | - "traefik.http.routers.default.rule=HostRegexp(`.+`)" 53 | - "traefik.http.routers.default.priority=1" 54 | - "traefik.http.routers.default.entrypoints=https" 55 | - "traefik.http.routers.default.tls=true" 56 | - "traefik.http.routers.default.service=default-errors" 57 | 58 | # default http to https 59 | # if you need a http website, don't worry, this router has priority 1 60 | - "traefik.http.middlewares.default-http.redirectscheme.permanent=true" 61 | - "traefik.http.middlewares.default-http.redirectscheme.scheme=https" 62 | - "traefik.http.routers.default-http.priority=1" 63 | - "traefik.http.routers.default-http.rule=HostRegexp(`.+`)" 64 | - "traefik.http.routers.default-http.entrypoints=http" 65 | - "traefik.http.routers.default-http.middlewares=default-http" 66 | - "traefik.http.routers.default-http.service=default-http" 67 | - "traefik.http.services.default-http.loadbalancer.passhostheader=true" 68 | 69 | # default errors 70 | - "traefik.http.middlewares.default-errors.errors.status=402-599" 71 | - "traefik.http.middlewares.default-errors.errors.query=/{status}" 72 | - "traefik.http.middlewares.default-errors.errors.service=default-errors" 73 | depends_on: 74 | socket-proxy: 75 | condition: "service_healthy" 76 | restart: true 77 | environment: 78 | TZ: "Europe/Zurich" 79 | command: 80 | - "--ping.terminatingStatusCode=204" # ping is needed for the health check to work! 81 | - "--global.checkNewVersion=false" 82 | - "--global.sendAnonymousUsage=false" 83 | - "--accesslog=true" 84 | - "--api.dashboard=true" 85 | - "--api.insecure=false" # disable insecure api and dashboard access 86 | - "--log.level=INFO" 87 | - "--log.format=json" 88 | - "--providers.docker.exposedByDefault=false" 89 | - "--providers.file.directory=/traefik/var" 90 | - "--entrypoints.http.address=:80" 91 | - "--entrypoints.http.http.middlewares=default-errors,default-ratelimit,default-ipallowlist-RFC1918" 92 | - "--entrypoints.https.address=:443" 93 | - "--entrypoints.https.http.middlewares=default-errors,default-ratelimit,default-ipallowlist-RFC1918" 94 | - "--serversTransport.insecureSkipVerify=true" # disable upstream HTTPS certificate checks (https > https) 95 | - "--experimental.plugins.rewriteResponseHeaders.moduleName=github.com/jamesmcroft/traefik-plugin-rewrite-response-headers" 96 | - "--experimental.plugins.rewriteResponseHeaders.version=v1.1.2" 97 | - "--experimental.plugins.geoblock.moduleName=github.com/PascalMinder/geoblock" 98 | - "--experimental.plugins.geoblock.version=v0.3.2" 99 | ports: 100 | - "80:80/tcp" 101 | - "443:443/tcp" 102 | volumes: 103 | - "var:/traefik/var" 104 | - "socket-proxy.run:/var/run" # access docker socket via proxy read-only 105 | - "plugins:/plugins-storage" # plugins stored as volume because of read-only 106 | networks: 107 | backend: 108 | frontend: 109 | sysctls: 110 | net.ipv4.ip_unprivileged_port_start: 80 # allow rootless container to access port 80 and higher 111 | restart: "always" 112 | 113 | nginx: # example container 114 | image: "11notes/nginx:stable" 115 | labels: 116 | - "traefik.enable=true" 117 | - "traefik.http.routers.nginx-example.rule=Host(`${NGINX_FQDN}`)" 118 | - "traefik.http.routers.nginx-example.entrypoints=https" 119 | - "traefik.http.routers.nginx-example.tls=true" 120 | - "traefik.http.routers.nginx-example.service=nginx-example" 121 | - "traefik.http.services.nginx-example.loadbalancer.server.port=3000" 122 | networks: 123 | backend: 124 | restart: "always" 125 | 126 | volumes: 127 | var: 128 | plugins: 129 | socket-proxy.run: 130 | 131 | networks: 132 | frontend: 133 | backend: 134 | internal: true -------------------------------------------------------------------------------- /errors.dockerfile: -------------------------------------------------------------------------------- 1 | ARG APP_VERSION=stable 2 | 3 | # :: Header 4 | FROM 11notes/express:${APP_VERSION} 5 | 6 | # :: arguments 7 | ENV EXPRESS_ERROR_TITLE="Traefik" 8 | 9 | # :: multi-stage 10 | COPY ./rootfs/errors /express/var 11 | 12 | # :: Monitor 13 | HEALTHCHECK --interval=5s --timeout=2s CMD ["/usr/local/bin/curl", "-kILs", "--fail", "-o", "/dev/null", "http://localhost:8080/ping"] -------------------------------------------------------------------------------- /project.md: -------------------------------------------------------------------------------- 1 | ${{ content_synopsis }} Run the prefer IaC reverse proxy distroless and rootless for maximum security. 2 | 3 | ${{ content_uvp }} Good question! All the other images on the market that do exactly the same don’t do or offer these options: 4 | 5 | ${{ github:> [!IMPORTANT] }} 6 | ${{ github:> }}* This image runs as 1000:1000 by default, most other images run everything as root 7 | ${{ github:> }}* This image has no shell since it is 100% distroless, most other images run on a distro like Debian or Alpine with full shell access (security) 8 | ${{ github:> }}* This image is created via a secure, pinned CI/CD process and immune to upstream attacks, most other images have upstream dependencies that can be exploited 9 | ${{ github:> }}* This image contains a proper health check that verifies the app is actually working, most other images have either no health check or only check if a port is open or ping works 10 | ${{ github:> }}* This image works as read-only, most other images need to write files to the image filesystem 11 | ${{ github:> }}* This image is a lot smaller than most other images 12 | 13 | If you value security, simplicity and the ability to interact with the maintainer and developer of an image. Using my images is a great start in that direction. 14 | 15 | ${{ content_comparison }} 16 | 17 | ${{ title_volumes }} 18 | * **${{ json_root }}/var** - Directory of all dynamic data and configurations 19 | 20 | ${{ content_compose }} 21 | 22 | ${{ content_defaults }} 23 | | `login` | admin // traefik | login using compose example | 24 | 25 | ${{ content_environment }} 26 | 27 | ${{ content_source }} 28 | 29 | ${{ content_parent }} 30 | 31 | ${{ content_built }} 32 | 33 | ${{ content_tips }} 34 | 35 | ${{ title_caution }} 36 | ${{ github:> [!CAUTION] }} 37 | ${{ github:> }}* If you use the compose example as a base for your individual configuration, please make sure to change the default dashboard login account password -------------------------------------------------------------------------------- /rootfs/errors/main.js: -------------------------------------------------------------------------------- 1 | 2 | const { readFileSync } = require('node:fs'); 3 | const Express = require('/Express'); 4 | const app = new Express(); 5 | 6 | const template = { 7 | default:readFileSync(`${__dirname}/template.default.html`).toString(), 8 | } 9 | 10 | const codeToError = (code, fqdn) => { 11 | const host = String(fqdn).toLowerCase(); 12 | switch(code){ 13 | case 400: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'bad request', COLOUR:'red'}); 14 | case 402: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'payment required', COLOUR:'blue'}); 15 | case 403: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'forbidden', COLOUR:'red'}); 16 | case 404: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'not found', COLOUR:'blue'}); 17 | case 405: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'method not allowed', COLOUR:'red'}); 18 | case 406: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Not Acceptable', COLOUR:'red'}); 19 | case 407: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Proxy Authentication Required', COLOUR:'red'}); 20 | case 408: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Request Timeout', COLOUR:'blue'}); 21 | case 409: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Conflict', COLOUR:'red'}); 22 | case 410: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Gone', COLOUR:'blue'}); 23 | case 411: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Length Required', COLOUR:'red'}); 24 | case 412: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Precondition Failed', COLOUR:'red'}); 25 | case 413: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Content Too Large', COLOUR:'red'}); 26 | case 414: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'URI Too Long', COLOUR:'red'}); 27 | case 415: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Unsupported Media Type', COLOUR:'red'}); 28 | case 416: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Range Not Satisfiable', COLOUR:'red'}); 29 | case 417: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Expectation Failed', COLOUR:'blue'}); 30 | case 418: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'I\'m a teapot', COLOUR:'orange'}); 31 | case 421: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Misdirected Request', COLOUR:'red'}); 32 | case 422: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Unprocessable Content', COLOUR:'red'}); 33 | case 423: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Locked', COLOUR:'red'}); 34 | case 424: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Failed Dependency', COLOUR:'red'}); 35 | case 425: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Too Early', COLOUR:'red'}); 36 | case 426: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Upgrade Required', COLOUR:'red'}); 37 | case 428: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Precondition Required', COLOUR:'red'}); 38 | case 429: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Too Many Requests', COLOUR:'purple'}); 39 | case 431: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Request Header Fields Too Large', COLOUR:'red'}); 40 | case 451: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Unavailable For Legal Reasons', COLOUR:'black'}); 41 | 42 | case 500: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Internal Server Error', COLOUR:'red'}); 43 | case 501: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Not Implemented', COLOUR:'red'}); 44 | case 502: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Bad Gateway', COLOUR:'blue'}); 45 | case 503: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Service Unavailable', COLOUR:'blue'}); 46 | case 504: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Gateway Timeout', COLOUR:'blue'}); 47 | case 505: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'HTTP Version Not Supported', COLOUR:'red'}); 48 | case 506: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Variant Also Negotiates', COLOUR:'blue'}); 49 | case 507: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Insufficient Storage', COLOUR:'red'}); 50 | case 508: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Loop Detected', COLOUR:'orange'}); 51 | case 510: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Not Extended', COLOUR:'blue'}); 52 | case 511: return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:code, ERROR:'Network Authentication Required', COLOUR:'red'}); 53 | } 54 | return({EXPRESS_ERROR_TITLE:process.env?.EXPRESS_ERROR_TITLE, template:template.default, FQDN:host, CODE:500, ERROR:'Internal Server Error', COLOUR:'red'}); 55 | } 56 | 57 | const generateHTML = (error) => { 58 | let html = error.template; 59 | for(const k in error){ 60 | html = html.replace(new RegExp(`\\\${${k}}`, 'ig'), error[k]); 61 | 62 | } 63 | return(html); 64 | } 65 | 66 | app.express.get('/', (req, res, next) => { 67 | console.log(req.hostname, req.path); 68 | const error = codeToError(404, req.hostname); 69 | res.status(error.CODE).end( 70 | generateHTML(error) 71 | ); 72 | }); 73 | 74 | app.express.get('/:code', (req, res, next) => { 75 | console.log(req.hostname, req.path); 76 | let code = 500; 77 | if(Number.isInteger(parseInt(req.params.code))){ 78 | code = parseInt(req.params.code); 79 | } 80 | const error = codeToError(code,req.hostname); 81 | res.status(error.CODE).end( 82 | generateHTML(error) 83 | ); 84 | }); 85 | 86 | app.start(); 87 | console.log('starting errors'); -------------------------------------------------------------------------------- /rootfs/errors/template.default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ${FQDN} 6 | 42 | 43 | 44 | 45 |
46 |
47 |
48 | ${EXPRESS_ERROR_TITLE} 49 |
50 | ${CODE} ${ERROR} 51 |
52 |
53 | 54 | 55 | 56 | --------------------------------------------------------------------------------