├── .gitignore ├── .gitattributes ├── .dockerignore ├── go └── socket-proxy │ ├── go.mod │ └── main.go ├── .json ├── .github └── workflows │ ├── tags.yml │ ├── readme.yml │ ├── version.yml │ ├── cve.yml │ └── docker.yml ├── LICENSE ├── arch.dockerfile ├── project.md ├── compose.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # default 2 | maintain/ 3 | node_modules/ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # default 2 | * text=auto 3 | *.sh eol=lf -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # default 2 | .git* 3 | maintain/ 4 | LICENSE 5 | *.md 6 | img/ 7 | node_modules/ -------------------------------------------------------------------------------- /go/socket-proxy/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/11notes/docker-socket-proxy 2 | go 1.25.0 3 | require github.com/11notes/go v1.1.2 -------------------------------------------------------------------------------- /.json: -------------------------------------------------------------------------------- 1 | { 2 | "image":"11notes/socket-proxy", 3 | "name":"socket-proxy", 4 | "root":"/", 5 | 6 | "semver":{ 7 | "version":"2.1.6" 8 | }, 9 | 10 | "readme":{ 11 | "description":"Access your docker socket safely as read-only, rootless and distroless", 12 | "distroless": { 13 | "layers": [ 14 | "11notes/distroless" 15 | ] 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /.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 | token: "${{ secrets.REPOSITORY_TOKEN }}" 15 | inputs: '{ "release":"true", "readme":"true" }' -------------------------------------------------------------------------------- /.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" }' -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/version.yml: -------------------------------------------------------------------------------- 1 | name: version 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: 'set version for build' 7 | type: string 8 | required: true 9 | jobs: 10 | version: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # ╔═════════════════════════════════════════════════════╗ 14 | # ║ BUILD VERSION {N} IMAGE ║ 15 | # ╚═════════════════════════════════════════════════════╝ 16 | - name: version / setup config 17 | uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298 18 | with: 19 | script: | 20 | const { Buffer } = require('node:buffer'); 21 | const etc = { 22 | version:"${{ github.event.inputs.version }}", 23 | semver:{disable:{rolling: true}} 24 | }; 25 | core.exportVariable('WORKFLOW_BASE64JSON', Buffer.from(JSON.stringify(etc)).toString('base64')); 26 | 27 | - name: version / build container image 28 | uses: the-actions-org/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7 29 | with: 30 | wait-for-completion: false 31 | workflow: docker.yml 32 | token: "${{ secrets.REPOSITORY_TOKEN }}" 33 | inputs: '{ "release":"false", "readme":"false", "etc":"${{ env.WORKFLOW_BASE64JSON }}" }' -------------------------------------------------------------------------------- /arch.dockerfile: -------------------------------------------------------------------------------- 1 | # ╔═════════════════════════════════════════════════════╗ 2 | # ║ SETUP ║ 3 | # ╚═════════════════════════════════════════════════════╝ 4 | # GLOBAL 5 | ARG APP_UID=1000 \ 6 | APP_GID=1000 \ 7 | BUILD_DIR=/go/socket-proxy 8 | ARG BUILD_BIN=${BUILD_DIR}/socket-proxy 9 | 10 | # :: FOREIGN IMAGES 11 | FROM 11notes/distroless AS distroless 12 | 13 | 14 | # ╔═════════════════════════════════════════════════════╗ 15 | # ║ BUILD ║ 16 | # ╚═════════════════════════════════════════════════════╝ 17 | # :: SOCKET-PROXY 18 | FROM 11notes/go:1.25 AS build 19 | ARG APP_VERSION \ 20 | BUILD_DIR \ 21 | BUILD_BIN 22 | 23 | COPY ./go/ /go 24 | 25 | RUN set -ex; \ 26 | cd ${BUILD_DIR}; \ 27 | eleven go build ${BUILD_BIN} main.go; \ 28 | eleven distroless ${BUILD_BIN}; 29 | 30 | # ╔═════════════════════════════════════════════════════╗ 31 | # ║ IMAGE ║ 32 | # ╚═════════════════════════════════════════════════════╝ 33 | # :: HEADER 34 | FROM scratch 35 | 36 | # :: default arguments 37 | ARG TARGETPLATFORM \ 38 | TARGETOS \ 39 | TARGETARCH \ 40 | TARGETVARIANT \ 41 | APP_IMAGE \ 42 | APP_NAME \ 43 | APP_VERSION \ 44 | APP_ROOT \ 45 | APP_UID \ 46 | APP_GID \ 47 | APP_NO_CACHE 48 | 49 | # :: default environment 50 | ENV APP_IMAGE=${APP_IMAGE} \ 51 | APP_NAME=${APP_NAME} \ 52 | APP_VERSION=${APP_VERSION} \ 53 | APP_ROOT=${APP_ROOT} 54 | 55 | # :: application specific environment 56 | ENV SOCKET_PROXY_VOLUME="/run/proxy" \ 57 | SOCKET_PROXY_DOCKER_SOCKET="/run/docker.sock" \ 58 | SOCKET_PROXY_UID=${APP_UID} \ 59 | SOCKET_PROXY_GID=${APP_GID} \ 60 | SOCKET_PROXY_KEEPALIVE="10s" \ 61 | SOCKET_PROXY_TIMEOUT="30s" 62 | 63 | # :: multi-stage 64 | COPY --from=distroless / / 65 | COPY --from=build /distroless/ / 66 | 67 | # :: PERSISTENT DATA 68 | HEALTHCHECK --interval=5s --timeout=3s --start-period=5s \ 69 | CMD ["/usr/local/bin/socket-proxy", "--healthcheck"] 70 | 71 | # :: EXECUTE 72 | ENTRYPOINT ["/usr/local/bin/socket-proxy"] -------------------------------------------------------------------------------- /.github/workflows/cve.yml: -------------------------------------------------------------------------------- 1 | name: cve 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "30 15 */2 * *" 7 | 8 | jobs: 9 | cve: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: init / checkout 13 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 14 | with: 15 | ref: ${{ github.ref_name }} 16 | fetch-depth: 0 17 | 18 | - name: init / setup environment 19 | uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298 20 | with: 21 | script: | 22 | const { existsSync, readFileSync } = require('node:fs'); 23 | const { resolve } = require('node:path'); 24 | const { inspect } = require('node:util'); 25 | const { Buffer } = require('node:buffer'); 26 | const inputs = `${{ toJSON(github.event.inputs) }}`; 27 | const opt = {input:{}, dot:{}}; 28 | 29 | try{ 30 | if(inputs.length > 0){ 31 | opt.input = JSON.parse(inputs); 32 | if(opt.input?.etc){ 33 | opt.input.etc = JSON.parse(Buffer.from(opt.input.etc, 'base64').toString('ascii')); 34 | } 35 | } 36 | }catch(e){ 37 | core.warning('could not parse github.event.inputs'); 38 | } 39 | 40 | try{ 41 | const path = resolve('.json'); 42 | if(existsSync(path)){ 43 | try{ 44 | opt.dot = JSON.parse(readFileSync(path).toString()); 45 | }catch(e){ 46 | throw new Error('could not parse .json'); 47 | } 48 | }else{ 49 | throw new Error('.json does not exist'); 50 | } 51 | }catch(e){ 52 | core.setFailed(e); 53 | } 54 | 55 | core.info(inspect(opt, {showHidden:false, depth:null, colors:true})); 56 | 57 | core.exportVariable('WORKFLOW_IMAGE', `${opt.dot.image}:${(opt.dot?.semver?.version === undefined) ? 'rolling' : opt.dot.semver.version}`); 58 | core.exportVariable('WORKFLOW_GRYPE_SEVERITY_CUTOFF', (opt.dot?.grype?.severity || 'high')); 59 | 60 | 61 | - name: grype / scan 62 | id: grype 63 | uses: anchore/scan-action@dc6246fcaf83ae86fcc6010b9824c30d7320729e 64 | with: 65 | image: ${{ env.WORKFLOW_IMAGE }} 66 | fail-build: true 67 | severity-cutoff: ${{ env.WORKFLOW_GRYPE_SEVERITY_CUTOFF }} 68 | output-format: 'sarif' 69 | by-cve: true 70 | cache-db: true -------------------------------------------------------------------------------- /project.md: -------------------------------------------------------------------------------- 1 | ${{ content_synopsis }} This image will run a proxy to access your docker socket as read-only. The exposed proxy socket is run as 1000:1000, not as root, although the image starts the proxy process as root to interact with the actual docker socket. There is also a TCP endpoint started at 2375 that will also proxy to the actual docker socket if needed. It is not exposed by default and must be exposed via using ```- "2375:2375/tcp"``` in your compose. 2 | 3 | Make sure that the docker socket is accessible by the ```user:``` specification in your compose, if the UID/GID are not correct, the image will print out the correct UID/GID for you to set: 4 | 5 | ```shell 6 | socket-proxy-1 | 2025/03/26 10:16:33 can’t access docker socket as GID 0 owned by GID 991 7 | socket-proxy-1 | please change the user setting in your compose to the correct UID/GID pair like this: 8 | socket-proxy-1 | services: 9 | socket-proxy-1 | socket-proxy: 10 | socket-proxy-1 | user: "0:991" 11 | ``` 12 | 13 | You find the list of all available Docker API endpoints [here](https://docs.docker.com/reference/api/engine/version/v1.51/). The following paths are still blocked, even though they are accesses only via GET: 14 | 15 | - [GET /containers/{id}/attach/ws](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Container/operation/ContainerAttachWebsocket) 16 | - [GET /containers/{id}/export](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Container/operation/ContainerExport) 17 | - [GET /containers/{id}/archive](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Container/operation/ContainerArchive) 18 | - [GET /secrets](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Secret/operation/SecretList) 19 | - [GET /configs](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Config) 20 | - [GET /swarm/unlockkey](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Swarm/operation/SwarmUnlockkey) 21 | - [GET /images/{name}/get](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Image/operation/ImageGet) 22 | 23 | ${{ content_uvp }} Good question! Because ... 24 | 25 | ${{ github:> [!IMPORTANT] }} 26 | ${{ github:> }}* ... this image exposes the socket not as root but as 1000:1000 27 | ${{ github:> }}* ... this image has no shell since it is [distroless](https://github.com/11notes/RTFM/blob/main/linux/container/image/distroless.md) 28 | ${{ github:> }}* ... this image is auto updated to the latest version via CI/CD 29 | ${{ github:> }}* ... this image has a health check 30 | ${{ github:> }}* ... this image runs read-only 31 | ${{ github:> }}* ... this image is automatically scanned for CVEs before and after publishing 32 | ${{ github:> }}* ... this image is created via a secure and pinned CI/CD process 33 | ${{ github:> }}* ... this image is very small 34 | 35 | If you value security, simplicity and optimizations to the extreme, then this image might be for you. 36 | 37 | ${{ content_compose }} 38 | 39 | ${{ content_environment }} 40 | | `SOCKET_PROXY_VOLUME` | path to the docker volume used to expose the prox socket | /run/proxy | 41 | | `SOCKET_PROXY_DOCKER_SOCKET` | path to the actual docker socket | /run/docker.sock | 42 | | `SOCKET_PROXY_UID` | the UID used to run the proxy parts | 1000 | 43 | | `SOCKET_PROXY_GID` | the GID used to run the proxy parts | 1000 | 44 | | `SOCKET_PROXY_KEEPALIVE` | connection keep alive interval to SOCKET_PROXY_DOCKER_SOCKET | 10s | 45 | | `SOCKET_PROXY_TIMEOUT` | connection max. timeout to SOCKET_PROXY_DOCKER_SOCKET | 30s | 46 | 47 | ${{ content_source }} 48 | 49 | ${{ content_parent }} 50 | 51 | ${{ content_built }} 52 | 53 | ${{ content_tips }} -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 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.6" 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 | traefik: 17 | depends_on: 18 | socket-proxy: 19 | condition: "service_healthy" 20 | restart: true 21 | image: "11notes/traefik:3.5.0" 22 | read_only: true 23 | labels: 24 | - "traefik.enable=true" 25 | 26 | # default errors middleware 27 | - "traefik.http.middlewares.default-errors.errors.status=402-599" 28 | - "traefik.http.middlewares.default-errors.errors.query=/{status}" 29 | - "traefik.http.middlewares.default-errors.errors.service=default-errors" 30 | 31 | # default ratelimit 32 | - "traefik.http.middlewares.default-ratelimit.ratelimit.average=100" 33 | - "traefik.http.middlewares.default-ratelimit.ratelimit.burst=120" 34 | - "traefik.http.middlewares.default-ratelimit.ratelimit.period=1s" 35 | 36 | # default CSP 37 | - "traefik.http.middlewares.default-csp.headers.contentSecurityPolicy=default-src 'self' blob: data: 'unsafe-inline'" 38 | 39 | # default allowlist 40 | - "traefik.http.middlewares.default-ipallowlist-RFC1918.ipallowlist.sourcerange=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" 41 | 42 | # example on how to secure the traefik dashboard and api 43 | - "traefik.http.routers.dashboard.rule=Host(`${TRAEFIK_FQDN}`)" 44 | - "traefik.http.routers.dashboard.service=api@internal" 45 | - "traefik.http.routers.dashboard.middlewares=dashboard-auth" 46 | - "traefik.http.routers.dashboard.entrypoints=https" 47 | # admin / traefik, please change! 48 | - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$2a$12$ktgZsFQZ0S1FeQbI1JjS9u36fAJMHDQaY6LNi9EkEp8sKtP5BK43C" 49 | 50 | # default catch-all router 51 | - "traefik.http.routers.default.rule=HostRegexp(`.+`)" 52 | - "traefik.http.routers.default.priority=1" 53 | - "traefik.http.routers.default.entrypoints=https" 54 | - "traefik.http.routers.default.service=default-errors" 55 | 56 | # default http to https redirection 57 | - "traefik.http.middlewares.default-http.redirectscheme.permanent=true" 58 | - "traefik.http.middlewares.default-http.redirectscheme.scheme=https" 59 | - "traefik.http.routers.default-http.priority=1" 60 | - "traefik.http.routers.default-http.rule=HostRegexp(`.+`)" 61 | - "traefik.http.routers.default-http.entrypoints=http" 62 | - "traefik.http.routers.default-http.middlewares=default-http" 63 | - "traefik.http.routers.default-http.service=default-http" 64 | - "traefik.http.services.default-http.loadbalancer.passhostheader=true" 65 | environment: 66 | TZ: "Europe/Zurich" 67 | command: 68 | # ping is needed for the health check to work! 69 | - "--ping=true" 70 | - "--ping.terminatingStatusCode=204" 71 | - "--global.checkNewVersion=false" 72 | - "--global.sendAnonymousUsage=false" 73 | - "--accesslog=true" 74 | - "--api.dashboard=true" 75 | # disable insecure api and dashboard access 76 | - "--api.insecure=false" 77 | - "--log.level=INFO" 78 | - "--log.format=json" 79 | - "--providers.docker.exposedByDefault=false" 80 | - "--providers.file.directory=/traefik/var" 81 | - "--entrypoints.http.address=:80" 82 | - "--entrypoints.http.http.middlewares=default-errors,default-ratelimit,default-ipallowlist-RFC1918,default-csp" 83 | - "--entrypoints.https.address=:443" 84 | - "--entrypoints.https.http.tls=true" 85 | - "--entrypoints.https.http.middlewares=default-errors,default-ratelimit,default-ipallowlist-RFC1918,default-csp" 86 | # disable upstream HTTPS certificate checks (https > https) 87 | - "--serversTransport.insecureSkipVerify=true" 88 | - "--experimental.plugins.rewriteResponseHeaders.moduleName=github.com/jamesmcroft/traefik-plugin-rewrite-response-headers" 89 | - "--experimental.plugins.rewriteResponseHeaders.version=v1.1.2" 90 | - "--experimental.plugins.geoblock.moduleName=github.com/PascalMinder/geoblock" 91 | - "--experimental.plugins.geoblock.version=v0.3.3" 92 | ports: 93 | - "80:80/tcp" 94 | - "443:443/tcp" 95 | volumes: 96 | - "var:/traefik/var" 97 | - "plugins:/traefik/plugins" 98 | # access docker socket via proxy read-only 99 | - "socket-proxy.run:/var/run" 100 | networks: 101 | backend: 102 | frontend: 103 | sysctls: 104 | # allow rootless container to access ports < 1024 105 | net.ipv4.ip_unprivileged_port_start: 80 106 | restart: "always" 107 | 108 | errors: 109 | # this image can be used to display a simple error message since Traefik can’t serve content 110 | image: "11notes/traefik:errors" 111 | read_only: true 112 | labels: 113 | - "traefik.enable=true" 114 | - "traefik.http.services.default-errors.loadbalancer.server.port=8080" 115 | environment: 116 | TZ: "Europe/Zurich" 117 | networks: 118 | backend: 119 | restart: "always" 120 | 121 | # example container 122 | nginx: 123 | image: "11notes/nginx:stable" 124 | read_only: true 125 | labels: 126 | - "traefik.enable=true" 127 | - "traefik.http.routers.nginx-example.rule=Host(`${NGINX_FQDN}`)" 128 | - "traefik.http.routers.nginx-example.entrypoints=https" 129 | - "traefik.http.routers.nginx-example.service=nginx-example" 130 | ports: 131 | - "3000:3000/tcp" 132 | tmpfs: 133 | # needed for read_only: true 134 | - "/nginx/cache:uid=1000,gid=1000" 135 | - "/nginx/run:uid=1000,gid=1000" 136 | networks: 137 | backend: 138 | restart: "always" 139 | 140 | volumes: 141 | var: 142 | plugins: 143 | socket-proxy.run: 144 | 145 | networks: 146 | frontend: 147 | backend: 148 | internal: true -------------------------------------------------------------------------------- /go/socket-proxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import( 4 | "net" 5 | "net/http" 6 | "net/url" 7 | "net/http/httputil" 8 | "context" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | "sync" 13 | "regexp" 14 | "strconv" 15 | "time" 16 | 17 | "github.com/11notes/go" 18 | ) 19 | 20 | var( 21 | Eleven eleven.New = eleven.New{} 22 | proxy *httputil.ReverseProxy 23 | socket net.Listener 24 | wg sync.WaitGroup 25 | socketProxy string 26 | keepAlive string = os.Getenv("SOCKET_PROXY_KEEPALIVE") 27 | timeout string = os.Getenv("SOCKET_PROXY_TIMEOUT") 28 | uid string = os.Getenv("SOCKET_PROXY_UID") 29 | gid string = os.Getenv("SOCKET_PROXY_GID") 30 | volume string = os.Getenv("SOCKET_PROXY_VOLUME") 31 | dockerSocket string = os.Getenv("SOCKET_PROXY_DOCKER_SOCKET") 32 | ) 33 | 34 | func prepareFileSystemDropPrivileges(){ 35 | // unprivileged user 36 | proxyUID, err := strconv.Atoi(uid) 37 | if err != nil { 38 | Eleven.LogFatal("SOCKET_PROXY_UID must be a number %v", err) 39 | } 40 | proxyGID, err := strconv.Atoi(gid) 41 | if err != nil { 42 | Eleven.LogFatal("SOCKET_PROXY_GID must be a number %v", err) 43 | } 44 | proxyVolume := regexp.MustCompile(`/+$`).ReplaceAllString(volume, "") 45 | 46 | // chown file system for unprivileged user 47 | if err := os.Chown(proxyVolume, proxyUID , proxyGID); err != nil { 48 | Eleven.LogFatal("could not chown folder %s", proxyVolume, err) 49 | } 50 | 51 | // check docker socket permissions 52 | stat, err := os.Stat(dockerSocket) 53 | if err != nil { 54 | Eleven.LogFatal("could not evaluate ownership of docker socket, permission issue %v", err) 55 | } 56 | if ownership, ok := stat.Sys().(*syscall.Stat_t); !ok { 57 | Eleven.LogFatal("could not evaluate ownership of docker socket, permission issue %v", err) 58 | }else{ 59 | if(int(ownership.Uid) != os.Getuid()){ 60 | Eleven.LogFatal("can’t access docker socket as UID %d owned by UID %d. Please change the user setting in your compose to the correct UID/GID pair like this >> user: %d:%d", os.Getuid(), ownership.Uid, ownership.Uid, ownership.Gid) 61 | }else{ 62 | if(int(ownership.Gid) != os.Getgid()){ 63 | Eleven.LogFatal("can’t access docker socket as GID %d owned by GID %d. Please change the user setting in your compose to the correct UID/GID pair like this >> user: %d:%d", os.Getgid(), ownership.Gid, os.Getuid(), ownership.Gid) 64 | } 65 | } 66 | } 67 | 68 | // drop privileges since only the proxy must access the socket as root and nothing else 69 | if err := syscall.Setgid(proxyGID); err != nil { 70 | Eleven.LogFatal("could not set GID to %d %v", proxyGID, err) 71 | } 72 | 73 | if err := syscall.Setuid(proxyUID); err != nil { 74 | Eleven.LogFatal("could not set UID to %d %v", proxyUID, err) 75 | } 76 | } 77 | 78 | func httpProxyBlockedPaths(url string) bool { 79 | // block paths that use GET but pose security risk 80 | blockedPatterns := []*regexp.Regexp{ 81 | regexp.MustCompile(`(?i)containers/\S+/attach/ws.*`), // could attach to stdin via web socket and issue command inside the container 82 | regexp.MustCompile(`(?i)containers/\S+/export.*`), // could exfil container data 83 | regexp.MustCompile(`(?i)containers/\S+/archive.*`), // could exfil container data 84 | regexp.MustCompile(`(?i)secrets.*`), // could exfil credentials 85 | regexp.MustCompile(`(?i)configs.*`), // could exfil credentials 86 | regexp.MustCompile(`(?i)swarm/unlockkey.*`), // could exfil credentials 87 | regexp.MustCompile(`(?i)images/get(/|)$`), // could exfil container data 88 | } 89 | 90 | for _, pattern := range blockedPatterns { 91 | if pattern.MatchString(url) { 92 | return true 93 | } 94 | } 95 | return false 96 | } 97 | 98 | func httpProxy(w http.ResponseWriter, r *http.Request){ 99 | method := r.Method 100 | url := r.URL.String() 101 | if((method == "GET" || method == "HEAD") && !httpProxyBlockedPaths(url)){ 102 | proxy.ServeHTTP(w, r) 103 | }else{ 104 | Eleven.Log("INF", "blocked: %s %s", method, url) 105 | http.Error(w, "", http.StatusForbidden) 106 | } 107 | } 108 | 109 | func healthcheck(exit bool){ 110 | healthcheckSockerDialer := &net.Dialer{Timeout: 2*time.Second} 111 | socket, err := healthcheckSockerDialer.Dial("unix", socketProxy) 112 | if err != nil { 113 | os.Exit(1) 114 | } 115 | err = socket.Close() 116 | if err != nil { 117 | os.Exit(1) 118 | } 119 | if(exit){ 120 | os.Exit(0) 121 | }else{ 122 | Eleven.Log("DBG", "health check successfully") 123 | } 124 | } 125 | 126 | func main(){ 127 | // set socket proxy file path 128 | socketProxy = regexp.MustCompile(`/+$`).ReplaceAllString(volume, "") + "/docker.sock" 129 | 130 | if(Eleven.Util.CommandLineArgumentExists("--healthcheck")){ 131 | // only run healthcheck 132 | healthcheck(true) 133 | }else{ 134 | // start app 135 | Eleven.Log("START", "") 136 | 137 | // setup signal handler 138 | signalChannel := make(chan os.Signal, 1) 139 | signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM, syscall.SIGSTOP, syscall.SIGINT) 140 | go func(){ 141 | <-signalChannel 142 | os.Exit(1) 143 | }() 144 | 145 | // setup proxy to docker socket as root 146 | keepAlive, err := time.ParseDuration(keepAlive) 147 | if err != nil { 148 | Eleven.LogFatal("%s not a valid time format: %s", keepAlive, err) 149 | } 150 | timeout, err := time.ParseDuration(timeout) 151 | if err != nil { 152 | Eleven.LogFatal("%s not a valid time format: %s", timeout, err) 153 | } 154 | localhost, _ := url.Parse("http://localhost") 155 | proxy = httputil.NewSingleHostReverseProxy(localhost) 156 | docketSockerDialer := &net.Dialer{KeepAlive: keepAlive, Timeout: timeout} 157 | proxy.Transport = &http.Transport{ 158 | DialContext:func(_ context.Context, _, _ string)(net.Conn, error){ 159 | return(docketSockerDialer.Dial("unix", dockerSocket)) 160 | }, 161 | } 162 | 163 | // prepare the file system and drop privileges to UID/GID 164 | prepareFileSystemDropPrivileges() 165 | 166 | // setup unix to socket proxy 167 | unixServer := &http.Server{ 168 | Handler: http.HandlerFunc(httpProxy), 169 | } 170 | os.Remove(socketProxy) 171 | unix, err := net.Listen("unix", socketProxy) 172 | if err != nil { 173 | Eleven.LogFatal("could not start unix socket %v", err) 174 | } 175 | wg.Add(1) 176 | go func(){ 177 | defer wg.Done() 178 | Eleven.Log("INF", "starting proxy UNIX socket ...") 179 | if err := unixServer.Serve(unix); err != nil { 180 | Eleven.LogFatal("could not start unix socket %v", err) 181 | } 182 | }() 183 | 184 | // setup http to socket proxy 185 | httpServer := &http.Server{ 186 | Handler: http.HandlerFunc(httpProxy), 187 | } 188 | 189 | tcp, err := net.Listen("tcp", "0.0.0.0:2375") 190 | if err != nil { 191 | Eleven.LogFatal("could not start tcp socket %v", err) 192 | } 193 | wg.Add(1) 194 | go func(){ 195 | defer wg.Done() 196 | Eleven.Log("INF", "starting proxy TCP socket ...") 197 | if err := httpServer.Serve(tcp); err != nil { 198 | Eleven.LogFatal("could not start tcp socket %v", err) 199 | } 200 | }() 201 | 202 | // try to access the socket proxy 203 | client := &http.Client{} 204 | req, err := http.NewRequest(http.MethodGet, "http://localhost:2375/version", nil) 205 | if err != nil { 206 | Eleven.LogFatal("could not create HTTP request %v", err) 207 | } 208 | res, err := client.Do(req) 209 | if err != nil { 210 | Eleven.LogFatal("could not proxy to docker socket %v", err) 211 | } 212 | res.Body.Close() 213 | if res.StatusCode != http.StatusOK { 214 | Eleven.LogFatal("could not proxy to docker socket %v", err) 215 | } 216 | 217 | // set internal socket check 218 | ticker := time.NewTicker(5 * time.Second) 219 | defer ticker.Stop() 220 | for { 221 | select { 222 | case <-ticker.C: 223 | healthcheck(false) 224 | } 225 | } 226 | 227 | // wait for socket to get stopped 228 | wg.Wait() 229 | } 230 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![banner](https://github.com/11notes/defaults/blob/main/static/img/banner.png?raw=true) 2 | 3 | # SOCKET-PROXY 4 | ![size](https://img.shields.io/docker/image-size/11notes/socket-proxy/2.1.6?color=0eb305)![5px](https://github.com/11notes/defaults/blob/main/static/img/transparent5x2px.png?raw=true)![version](https://img.shields.io/docker/v/11notes/socket-proxy/2.1.6?color=eb7a09)![5px](https://github.com/11notes/defaults/blob/main/static/img/transparent5x2px.png?raw=true)![pulls](https://img.shields.io/docker/pulls/11notes/socket-proxy?color=2b75d6)![5px](https://github.com/11notes/defaults/blob/main/static/img/transparent5x2px.png?raw=true)[](https://github.com/11notes/docker-SOCKET-PROXY/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,PHN2ZyB2ZXJzaW9uPSIxIiB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDMyIDMyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxyZWN0IHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0idHJhbnNwYXJlbnQiLz4KICA8cGF0aCBkPSJtMTMgNmg2djdoN3Y2aC03djdoLTZ2LTdoLTd2LTZoN3oiIGZpbGw9IiNmZmYiLz4KPC9zdmc+) 5 | 6 | Access your docker socket safely as read-only, rootless and distroless 7 | 8 | # SYNOPSIS 📖 9 | **What can I do with this?** This image will run a proxy to access your docker socket as read-only. The exposed proxy socket is run as 1000:1000, not as root, although the image starts the proxy process as root to interact with the actual docker socket. There is also a TCP endpoint started at 2375 that will also proxy to the actual docker socket if needed. It is not exposed by default and must be exposed via using ```- "2375:2375/tcp"``` in your compose. 10 | 11 | Make sure that the docker socket is accessible by the ```user:``` specification in your compose, if the UID/GID are not correct, the image will print out the correct UID/GID for you to set: 12 | 13 | ```shell 14 | socket-proxy-1 | 2025/03/26 10:16:33 can’t access docker socket as GID 0 owned by GID 991 15 | socket-proxy-1 | please change the user setting in your compose to the correct UID/GID pair like this: 16 | socket-proxy-1 | services: 17 | socket-proxy-1 | socket-proxy: 18 | socket-proxy-1 | user: "0:991" 19 | ``` 20 | 21 | You find the list of all available Docker API endpoints [here](https://docs.docker.com/reference/api/engine/version/v1.51/). The following paths are still blocked, even though they are accesses only via GET: 22 | 23 | - [GET /containers/{id}/attach/ws](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Container/operation/ContainerAttachWebsocket) 24 | - [GET /containers/{id}/export](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Container/operation/ContainerExport) 25 | - [GET /containers/{id}/archive](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Container/operation/ContainerArchive) 26 | - [GET /secrets](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Secret/operation/SecretList) 27 | - [GET /configs](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Config) 28 | - [GET /swarm/unlockkey](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Swarm/operation/SwarmUnlockkey) 29 | - [GET /images/{name}/get](https://docs.docker.com/reference/api/engine/version/v1.51/#tag/Image/operation/ImageGet) 30 | 31 | # UNIQUE VALUE PROPOSITION 💶 32 | **Why should I run this image and not the other image(s) that already exist?** Good question! Because ... 33 | 34 | > [!IMPORTANT] 35 | >* ... this image exposes the socket not as root but as 1000:1000 36 | >* ... this image has no shell since it is [distroless](https://github.com/11notes/RTFM/blob/main/linux/container/image/distroless.md) 37 | >* ... this image is auto updated to the latest version via CI/CD 38 | >* ... this image has a health check 39 | >* ... this image runs read-only 40 | >* ... this image is automatically scanned for CVEs before and after publishing 41 | >* ... this image is created via a secure and pinned CI/CD process 42 | >* ... this image is very small 43 | 44 | If you value security, simplicity and optimizations to the extreme, then this image might be for you. 45 | 46 | # COMPOSE ✂️ 47 | ```yaml 48 | name: "reverse-proxy" 49 | services: 50 | socket-proxy: 51 | # this image is used to expose the docker socket as read-only to traefik 52 | # you can check https://github.com/11notes/docker-socket-proxy for all details 53 | image: "11notes/socket-proxy:2.1.6" 54 | read_only: true 55 | user: "0:0" 56 | environment: 57 | TZ: "Europe/Zurich" 58 | volumes: 59 | - "/run/docker.sock:/run/docker.sock:ro" 60 | - "socket-proxy.run:/run/proxy" 61 | restart: "always" 62 | 63 | traefik: 64 | depends_on: 65 | socket-proxy: 66 | condition: "service_healthy" 67 | restart: true 68 | image: "11notes/traefik:3.5.0" 69 | read_only: true 70 | labels: 71 | - "traefik.enable=true" 72 | 73 | # default errors middleware 74 | - "traefik.http.middlewares.default-errors.errors.status=402-599" 75 | - "traefik.http.middlewares.default-errors.errors.query=/{status}" 76 | - "traefik.http.middlewares.default-errors.errors.service=default-errors" 77 | 78 | # default ratelimit 79 | - "traefik.http.middlewares.default-ratelimit.ratelimit.average=100" 80 | - "traefik.http.middlewares.default-ratelimit.ratelimit.burst=120" 81 | - "traefik.http.middlewares.default-ratelimit.ratelimit.period=1s" 82 | 83 | # default CSP 84 | - "traefik.http.middlewares.default-csp.headers.contentSecurityPolicy=default-src 'self' blob: data: 'unsafe-inline'" 85 | 86 | # default allowlist 87 | - "traefik.http.middlewares.default-ipallowlist-RFC1918.ipallowlist.sourcerange=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" 88 | 89 | # example on how to secure the traefik dashboard and api 90 | - "traefik.http.routers.dashboard.rule=Host(`${TRAEFIK_FQDN}`)" 91 | - "traefik.http.routers.dashboard.service=api@internal" 92 | - "traefik.http.routers.dashboard.middlewares=dashboard-auth" 93 | - "traefik.http.routers.dashboard.entrypoints=https" 94 | # admin / traefik, please change! 95 | - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$2a$12$ktgZsFQZ0S1FeQbI1JjS9u36fAJMHDQaY6LNi9EkEp8sKtP5BK43C" 96 | 97 | # default catch-all router 98 | - "traefik.http.routers.default.rule=HostRegexp(`.+`)" 99 | - "traefik.http.routers.default.priority=1" 100 | - "traefik.http.routers.default.entrypoints=https" 101 | - "traefik.http.routers.default.service=default-errors" 102 | 103 | # default http to https redirection 104 | - "traefik.http.middlewares.default-http.redirectscheme.permanent=true" 105 | - "traefik.http.middlewares.default-http.redirectscheme.scheme=https" 106 | - "traefik.http.routers.default-http.priority=1" 107 | - "traefik.http.routers.default-http.rule=HostRegexp(`.+`)" 108 | - "traefik.http.routers.default-http.entrypoints=http" 109 | - "traefik.http.routers.default-http.middlewares=default-http" 110 | - "traefik.http.routers.default-http.service=default-http" 111 | - "traefik.http.services.default-http.loadbalancer.passhostheader=true" 112 | environment: 113 | TZ: "Europe/Zurich" 114 | command: 115 | # ping is needed for the health check to work! 116 | - "--ping=true" 117 | - "--ping.terminatingStatusCode=204" 118 | - "--global.checkNewVersion=false" 119 | - "--global.sendAnonymousUsage=false" 120 | - "--accesslog=true" 121 | - "--api.dashboard=true" 122 | # disable insecure api and dashboard access 123 | - "--api.insecure=false" 124 | - "--log.level=INFO" 125 | - "--log.format=json" 126 | - "--providers.docker.exposedByDefault=false" 127 | - "--providers.file.directory=/traefik/var" 128 | - "--entrypoints.http.address=:80" 129 | - "--entrypoints.http.http.middlewares=default-errors,default-ratelimit,default-ipallowlist-RFC1918,default-csp" 130 | - "--entrypoints.https.address=:443" 131 | - "--entrypoints.https.http.tls=true" 132 | - "--entrypoints.https.http.middlewares=default-errors,default-ratelimit,default-ipallowlist-RFC1918,default-csp" 133 | # disable upstream HTTPS certificate checks (https > https) 134 | - "--serversTransport.insecureSkipVerify=true" 135 | - "--experimental.plugins.rewriteResponseHeaders.moduleName=github.com/jamesmcroft/traefik-plugin-rewrite-response-headers" 136 | - "--experimental.plugins.rewriteResponseHeaders.version=v1.1.2" 137 | - "--experimental.plugins.geoblock.moduleName=github.com/PascalMinder/geoblock" 138 | - "--experimental.plugins.geoblock.version=v0.3.3" 139 | ports: 140 | - "80:80/tcp" 141 | - "443:443/tcp" 142 | volumes: 143 | - "var:/traefik/var" 144 | - "plugins:/traefik/plugins" 145 | # access docker socket via proxy read-only 146 | - "socket-proxy.run:/var/run" 147 | networks: 148 | backend: 149 | frontend: 150 | sysctls: 151 | # allow rootless container to access ports < 1024 152 | net.ipv4.ip_unprivileged_port_start: 80 153 | restart: "always" 154 | 155 | errors: 156 | # this image can be used to display a simple error message since Traefik can’t serve content 157 | image: "11notes/traefik:errors" 158 | read_only: true 159 | labels: 160 | - "traefik.enable=true" 161 | - "traefik.http.services.default-errors.loadbalancer.server.port=8080" 162 | environment: 163 | TZ: "Europe/Zurich" 164 | networks: 165 | backend: 166 | restart: "always" 167 | 168 | # example container 169 | nginx: 170 | image: "11notes/nginx:stable" 171 | read_only: true 172 | labels: 173 | - "traefik.enable=true" 174 | - "traefik.http.routers.nginx-example.rule=Host(`${NGINX_FQDN}`)" 175 | - "traefik.http.routers.nginx-example.entrypoints=https" 176 | - "traefik.http.routers.nginx-example.service=nginx-example" 177 | ports: 178 | - "3000:3000/tcp" 179 | tmpfs: 180 | # needed for read_only: true 181 | - "/nginx/cache:uid=1000,gid=1000" 182 | - "/nginx/run:uid=1000,gid=1000" 183 | networks: 184 | backend: 185 | restart: "always" 186 | 187 | volumes: 188 | var: 189 | plugins: 190 | socket-proxy.run: 191 | 192 | networks: 193 | frontend: 194 | backend: 195 | internal: true 196 | ``` 197 | To find out how you can change the default UID/GID of this container image, consult the [how-to.changeUIDGID](https://github.com/11notes/RTFM/blob/main/linux/container/image/11notes/how-to.changeUIDGID.md#change-uidgid-the-correct-way) section of my [RTFM](https://github.com/11notes/RTFM) 198 | 199 | # ENVIRONMENT 📝 200 | | Parameter | Value | Default | 201 | | --- | --- | --- | 202 | | `TZ` | [Time Zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) | | 203 | | `DEBUG` | Will activate debug option for container image and app (if available) | | 204 | | `SOCKET_PROXY_VOLUME` | path to the docker volume used to expose the prox socket | /run/proxy | 205 | | `SOCKET_PROXY_DOCKER_SOCKET` | path to the actual docker socket | /run/docker.sock | 206 | | `SOCKET_PROXY_UID` | the UID used to run the proxy parts | 1000 | 207 | | `SOCKET_PROXY_GID` | the GID used to run the proxy parts | 1000 | 208 | | `SOCKET_PROXY_KEEPALIVE` | connection keep alive interval to SOCKET_PROXY_DOCKER_SOCKET | 10s | 209 | | `SOCKET_PROXY_TIMEOUT` | connection max. timeout to SOCKET_PROXY_DOCKER_SOCKET | 30s | 210 | 211 | # MAIN TAGS 🏷️ 212 | These are the main tags for the image. There is also a tag for each commit and its shorthand sha256 value. 213 | 214 | * [2.1.6](https://hub.docker.com/r/11notes/socket-proxy/tags?name=2.1.6) 215 | 216 | ### There is no latest tag, what am I supposed to do about updates? 217 | It is of my opinion that the ```:latest``` tag is 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 ```:2.1.6``` you can use ```:2``` or ```:2.1```. 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. 218 | 219 | 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! 220 | 221 | # REGISTRIES ☁️ 222 | ``` 223 | docker pull 11notes/socket-proxy:2.1.6 224 | docker pull ghcr.io/11notes/socket-proxy:2.1.6 225 | docker pull quay.io/11notes/socket-proxy:2.1.6 226 | ``` 227 | 228 | # SOURCE 💾 229 | * [11notes/socket-proxy](https://github.com/11notes/docker-SOCKET-PROXY) 230 | 231 | # PARENT IMAGE 🏛️ 232 | > [!IMPORTANT] 233 | >This image is not based on another image but uses [scratch](https://hub.docker.com/_/scratch) as the starting layer. 234 | >The image consists of the following distroless layers that were added: 235 | >* [11notes/distroless](https://github.com/11notes/docker-distroless/blob/master/arch.dockerfile) - contains users, timezones and Root CA certificates, nothing else 236 | 237 | 238 | 239 | # GENERAL TIPS 📌 240 | > [!TIP] 241 | >* Use a reverse proxy like Traefik, Nginx, HAproxy to terminate TLS and to protect your endpoints 242 | >* Use Let’s Encrypt DNS-01 challenge to obtain valid SSL certificates for your services 243 | 244 | # ElevenNotes™️ 245 | 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-socket-proxy/releases) for breaking changes. If you have any problems with using this image simply raise an [issue](https://github.com/11notes/docker-socket-proxy/issues), thanks. If you have a question or inputs please create a new [discussion](https://github.com/11notes/docker-socket-proxy/discussions) instead of an issue. You can find all my other repositories on [github](https://github.com/11notes?tab=repositories). 246 | 247 | *created 23.10.2025, 00:16:19 (CET)* -------------------------------------------------------------------------------- /.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 | platform: 14 | description: 'list of platforms to build for' 15 | type: string 16 | required: false 17 | default: "amd64,arm64,arm/v7" 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 | # ╔═════════════════════════════════════════════════════╗ 40 | # ║ ║ 41 | # ║ ║ 42 | # ║ CREATE PLATFORM MATRIX ║ 43 | # ║ ║ 44 | # ║ ║ 45 | # ╚═════════════════════════════════════════════════════╝ 46 | matrix: 47 | name: create job matrix 48 | runs-on: ubuntu-latest 49 | outputs: 50 | stringify: ${{ steps.setup-matrix.outputs.stringify }} 51 | 52 | steps: 53 | # CHECKOUT REPOSITORY 54 | - name: init / checkout 55 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 56 | with: 57 | ref: ${{ github.ref_name }} 58 | 59 | - name: matrix / setup list 60 | id: setup-matrix 61 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 62 | with: 63 | script: | 64 | const { existsSync, readFileSync } = require('node:fs'); 65 | const { inspect } = require('node:util'); 66 | const { resolve } = require('node:path'); 67 | const opt = {dot:{}}; 68 | 69 | try{ 70 | const path = resolve('.json'); 71 | if(existsSync(path)){ 72 | try{ 73 | opt.dot = JSON.parse(readFileSync(path).toString()); 74 | }catch(e){ 75 | throw new Error('could not parse .json'); 76 | } 77 | }else{ 78 | throw new Error('.json does not exist'); 79 | } 80 | }catch(e){ 81 | core.setFailed(e); 82 | } 83 | 84 | const platforms = ( 85 | ("${{ github.event.inputs.platform }}" != "amd64,arm64,arm/v7") ? "${{ github.event.inputs.platform }}".split(",") : ( 86 | (opt.dot?.platform) ? opt.dot.platform.split(",") : "${{ github.event.inputs.platform }}".split(",") 87 | ) 88 | ); 89 | 90 | const matrix = {include:[]}; 91 | for(const platform of platforms){ 92 | switch(platform){ 93 | case "amd64": matrix.include.push({platform:platform, runner:"ubuntu-24.04"}); break; 94 | case "arm64": matrix.include.push({platform:platform, runner:"ubuntu-24.04-arm"}); break; 95 | case "arm/v7": matrix.include.push({platform:platform, runner:"ubuntu-24.04-arm"}); break; 96 | } 97 | } 98 | 99 | const stringify = JSON.stringify(matrix); 100 | core.setOutput('stringify', stringify); 101 | 102 | // print 103 | core.info(inspect({opt:opt, matrix:matrix, platforms:platforms}, {showHidden:false, depth:null, colors:true})); 104 | 105 | 106 | # ╔═════════════════════════════════════════════════════╗ 107 | # ║ ║ 108 | # ║ ║ 109 | # ║ BUILD CONTAINER IMAGE ║ 110 | # ║ ║ 111 | # ║ ║ 112 | # ╚═════════════════════════════════════════════════════╝ 113 | docker: 114 | name: create container image 115 | runs-on: ${{ matrix.runner }} 116 | strategy: 117 | fail-fast: false 118 | matrix: ${{ fromJSON(needs.matrix.outputs.stringify) }} 119 | outputs: 120 | DOCKER_IMAGE_NAME: ${{ steps.setup-environment.outputs.DOCKER_IMAGE_NAME }} 121 | DOCKER_IMAGE_MERGE_TAGS: ${{ steps.setup-environment.outputs.DOCKER_IMAGE_MERGE_TAGS }} 122 | DOCKER_IMAGE_DESCRIPTION: ${{ steps.setup-environment.outputs.DOCKER_IMAGE_DESCRIPTION }} 123 | DOCKER_IMAGE_NAME_AND_VERSION: ${{ steps.setup-environment.outputs.DOCKER_IMAGE_NAME_AND_VERSION }} 124 | DOCKER_IMAGE_ARGUMENTS: ${{ steps.setup-environment.outputs.DOCKER_IMAGE_ARGUMENTS }} 125 | WORKFLOW_BUILD: ${{ steps.setup-environment.outputs.WORKFLOW_BUILD }} 126 | 127 | timeout-minutes: 1440 128 | 129 | services: 130 | registry: 131 | image: registry:2 132 | ports: 133 | - 5000:5000 134 | 135 | permissions: 136 | actions: write 137 | contents: write 138 | packages: write 139 | attestations: write 140 | id-token: write 141 | security-events: write 142 | 143 | needs: matrix 144 | 145 | steps: 146 | # ╔═════════════════════════════════════════════════════╗ 147 | # ║ SETUP ENVIRONMENT ║ 148 | # ╚═════════════════════════════════════════════════════╝ 149 | # CHECKOUT ALL DEPTHS (ALL TAGS) 150 | - name: init / checkout 151 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 152 | with: 153 | ref: ${{ github.ref_name }} 154 | fetch-depth: 0 155 | 156 | # SETUP ENVIRONMENT VARIABLES AND INPUTS 157 | - name: init / setup environment 158 | id: setup-environment 159 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 160 | with: 161 | script: | 162 | const { existsSync, readFileSync } = require('node:fs'); 163 | const { resolve } = require('node:path'); 164 | const { inspect } = require('node:util'); 165 | const { Buffer } = require('node:buffer'); 166 | const inputs = `${{ toJSON(github.event.inputs) }}`. 167 | replace(/"platform":\s*"\[(.+)\]",/i, `"platform": [$1],`); 168 | const opt = {input:{}, dot:{}}; 169 | 170 | try{ 171 | if(inputs.length > 0){ 172 | opt.input = JSON.parse(inputs); 173 | if(opt.input?.etc){ 174 | opt.input.etc = JSON.parse(Buffer.from(opt.input.etc, 'base64').toString('ascii')); 175 | } 176 | } 177 | }catch(e){ 178 | core.warning('could not parse github.event.inputs'); 179 | core.warning(inputs); 180 | } 181 | 182 | try{ 183 | const path = resolve('.json'); 184 | if(existsSync(path)){ 185 | try{ 186 | opt.dot = JSON.parse(readFileSync(path).toString()); 187 | }catch(e){ 188 | throw new Error('could not parse .json'); 189 | } 190 | }else{ 191 | throw new Error('.json does not exist'); 192 | } 193 | }catch(e){ 194 | core.setFailed(e); 195 | } 196 | 197 | const docker = { 198 | image:{ 199 | name:opt.dot.image, 200 | arch:(opt.input?.etc?.arch || opt.dot?.arch || 'linux/amd64,linux/arm64'), 201 | prefix:((opt.input?.etc?.semverprefix) ? `${opt.input?.etc?.semverprefix}-` : ''), 202 | suffix:((opt.input?.etc?.semversuffix) ? `-${opt.input?.etc?.semversuffix}` : ''), 203 | description:(opt.dot?.readme?.description || ''), 204 | platform:{ 205 | sanitized:"${{ matrix.platform }}".replace(/[^A-Z-a-z0-9]+/i, ""), 206 | }, 207 | tags:[], 208 | build:(opt.input?.build === undefined) ? false : opt.input.build, 209 | }, 210 | app:{ 211 | image:opt.dot.image, 212 | name:opt.dot.name, 213 | version:(opt.input?.etc?.version || opt.dot?.semver?.version), 214 | root:opt.dot.root, 215 | UID:(opt.input?.etc?.uid || 1000), 216 | GID:(opt.input?.etc?.gid || 1000), 217 | no_cache:new Date().getTime(), 218 | }, 219 | cache:{ 220 | registry:'localhost:5000/', 221 | enable:(opt.input?.etc?.cache === undefined) ? true : opt.input.etc.cache, 222 | }, 223 | tags:[], 224 | merge_tags:[], 225 | }; 226 | 227 | docker.cache.name = `${docker.image.name}:${docker.image.prefix}buildcache${docker.image.suffix}`; 228 | docker.cache.grype = `${docker.cache.registry}${docker.image.name}:${docker.image.prefix}grype${docker.image.suffix}`; 229 | docker.app.prefix = docker.image.prefix; 230 | docker.app.suffix = docker.image.suffix; 231 | 232 | const semver = docker.app.version.split('.'); 233 | // setup tags 234 | if(!opt.dot?.semver?.disable?.rolling && !opt.input.etc?.semver?.disable?.rolling){ 235 | docker.image.tags.push('rolling'); 236 | } 237 | if(opt.input?.etc?.dockerfile !== 'arch.dockerfile' && opt.input?.etc?.tag){ 238 | docker.image.tags.push(opt.input.etc.tag); 239 | if(Array.isArray(semver)){ 240 | if(semver.length >= 1) docker.image.tags.push(`${opt.input.etc.tag}-${semver[0]}`); 241 | if(semver.length >= 2) docker.image.tags.push(`${opt.input.etc.tag}-${semver[0]}.${semver[1]}`); 242 | if(semver.length >= 3) docker.image.tags.push(`${opt.input.etc.tag}-${semver[0]}.${semver[1]}.${semver[2]}`); 243 | }else{ 244 | docker.image.tags.push(`${opt.input.etc.tag}-${docker.app.version}`); 245 | } 246 | docker.cache.name = `${docker.image.name}:buildcache-${opt.input.etc.tag}`; 247 | }else if(docker.app.version !== 'latest'){ 248 | if(Array.isArray(semver)){ 249 | if(semver.length >= 1) docker.image.tags.push(`${semver[0]}`); 250 | if(semver.length >= 2) docker.image.tags.push(`${semver[0]}.${semver[1]}`); 251 | if(semver.length >= 3) docker.image.tags.push(`${semver[0]}.${semver[1]}.${semver[2]}`); 252 | } 253 | if(opt.dot?.semver?.stable && new RegExp(opt.dot?.semver.stable, 'ig').test(docker.image.tags.join(','))) docker.image.tags.push('stable'); 254 | if(opt.dot?.semver?.latest && new RegExp(opt.dot?.semver.latest, 'ig').test(docker.image.tags.join(','))) docker.image.tags.push('latest'); 255 | }else{ 256 | docker.image.tags.push('latest'); 257 | } 258 | 259 | for(const tag of docker.image.tags){ 260 | docker.tags.push(`${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}-${docker.image.platform.sanitized}`); 261 | docker.tags.push(`ghcr.io/${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}-${docker.image.platform.sanitized}`); 262 | docker.tags.push(`quay.io/${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}-${docker.image.platform.sanitized}`); 263 | docker.merge_tags.push(`${docker.image.prefix}${tag}${docker.image.suffix}`); 264 | } 265 | 266 | // setup build arguments 267 | if(opt.input?.etc?.build?.args){ 268 | for(const arg in opt.input.etc.build.args){ 269 | docker.app[arg] = opt.input.etc.build.args[arg]; 270 | } 271 | } 272 | if(opt.dot?.build?.args){ 273 | for(const arg in opt.dot.build.args){ 274 | docker.app[arg] = opt.dot.build.args[arg]; 275 | } 276 | } 277 | const arguments = []; 278 | for(const argument in docker.app){ 279 | arguments.push(`APP_${argument.toUpperCase()}=${docker.app[argument]}`); 280 | } 281 | 282 | // export to environment 283 | core.exportVariable('DOCKER_CACHE_REGISTRY', docker.cache.registry); 284 | core.exportVariable('DOCKER_CACHE_NAME', `${docker.cache.name}-${docker.image.platform.sanitized}`); 285 | core.exportVariable('DOCKER_CACHE_GRYPE', docker.cache.grype); 286 | 287 | core.exportVariable('DOCKER_IMAGE_NAME', docker.image.name); 288 | core.setOutput('DOCKER_IMAGE_NAME', docker.image.name); 289 | core.exportVariable('DOCKER_IMAGE_TAGS', docker.tags.join(',')); 290 | core.exportVariable('DOCKER_IMAGE_MERGE_TAGS', docker.merge_tags.join("\r\n")); 291 | core.setOutput('DOCKER_IMAGE_MERGE_TAGS', docker.merge_tags.join("\r\n")); 292 | core.exportVariable('DOCKER_IMAGE_DESCRIPTION', docker.image.description); 293 | core.setOutput('DOCKER_IMAGE_DESCRIPTION', docker.image.description); 294 | core.exportVariable('DOCKER_IMAGE_ARGUMENTS', arguments.join("\r\n")); 295 | core.setOutput('DOCKER_IMAGE_ARGUMENTS', arguments.join("\r\n")); 296 | core.exportVariable('DOCKER_IMAGE_DOCKERFILE', opt.input?.etc?.dockerfile || 'arch.dockerfile'); 297 | core.exportVariable('DOCKER_IMAGE_PLATFORM_SANITIZED', docker.image.platform.sanitized); 298 | core.exportVariable('DOCKER_IMAGE_NAME_AND_VERSION', `${docker.image.name}:${docker.app.version}`); 299 | core.setOutput('DOCKER_IMAGE_NAME_AND_VERSION', `${docker.image.name}:${docker.app.version}`); 300 | 301 | core.exportVariable('WORKFLOW_BUILD', docker.image.build); 302 | core.setOutput('WORKFLOW_BUILD', docker.image.build); 303 | core.exportVariable('WORKFLOW_BUILD_NO_CACHE', !docker.cache.enable); 304 | 305 | core.exportVariable('WORKFLOW_CREATE_RELEASE', (opt.input?.release === undefined) ? false : opt.input.release); 306 | core.exportVariable('WORKFLOW_CREATE_README', (opt.input?.readme === undefined) ? false : opt.input.readme); 307 | core.exportVariable('WORKFLOW_GRYPE_FAIL_ON_SEVERITY', (opt.dot?.grype?.fail === undefined) ? true : opt.dot.grype.fail); 308 | core.exportVariable('WORKFLOW_GRYPE_SEVERITY_CUTOFF', (opt.dot?.grype?.severity || 'critical')); 309 | 310 | // print 311 | core.info(inspect({opt:opt, docker:docker}, {showHidden:false, depth:null, colors:true})); 312 | 313 | 314 | # ╔═════════════════════════════════════════════════════╗ 315 | # ║ CONTAINER REGISTRY LOGIN ║ 316 | # ╚═════════════════════════════════════════════════════╝ 317 | # DOCKER HUB 318 | - name: docker / login to hub 319 | uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 320 | with: 321 | username: 11notes 322 | password: ${{ secrets.DOCKER_TOKEN }} 323 | 324 | # GITHUB CONTAINER REGISTRY 325 | - name: github / login to ghcr 326 | uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 327 | with: 328 | registry: ghcr.io 329 | username: 11notes 330 | password: ${{ secrets.GITHUB_TOKEN }} 331 | 332 | # REDHAT QUAY 333 | - name: quay / login to quay 334 | uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 335 | with: 336 | registry: quay.io 337 | username: 11notes+github 338 | password: ${{ secrets.QUAY_TOKEN }} 339 | 340 | 341 | # ╔═════════════════════════════════════════════════════╗ 342 | # ║ BUILD CONTAINER IMAGE ║ 343 | # ╚═════════════════════════════════════════════════════╝ 344 | # SETUP QEMU 345 | - name: container image / setup qemu 346 | if: env.WORKFLOW_BUILD == 'true' && matrix.platform == 'arm/v7' 347 | uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 348 | with: 349 | image: tonistiigi/binfmt:qemu-v8.1.5 350 | cache-image: false 351 | 352 | # SETUP BUILDX BUILDER WITH USING LOCAL REGISTRY 353 | - name: container image / setup buildx 354 | if: env.WORKFLOW_BUILD == 'true' 355 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 356 | with: 357 | driver-opts: network=host 358 | 359 | # BUILD CONTAINER IMAGE FROM GLOBAL CACHE (DOCKER HUB) AND PUSH TO LOCAL CACHE 360 | - name: container image / build 361 | if: env.WORKFLOW_BUILD == 'true' 362 | id: image-build 363 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 364 | with: 365 | context: . 366 | no-cache: ${{ env.WORKFLOW_BUILD_NO_CACHE }} 367 | file: ${{ env.DOCKER_IMAGE_DOCKERFILE }} 368 | push: true 369 | platforms: linux/${{ matrix.platform }} 370 | cache-from: type=registry,ref=${{ env.DOCKER_CACHE_NAME }} 371 | cache-to: type=registry,ref=${{ env.DOCKER_CACHE_REGISTRY }}${{ env.DOCKER_CACHE_NAME }},mode=max,compression=zstd,force-compression=true 372 | build-args: | 373 | ${{ env.DOCKER_IMAGE_ARGUMENTS }} 374 | tags: | 375 | ${{ env.DOCKER_CACHE_GRYPE }} 376 | 377 | # SCAN LOCAL CONTAINER IMAGE WITH GRYPE 378 | - name: container image / scan with grype 379 | if: env.WORKFLOW_BUILD == 'true' 380 | id: grype 381 | uses: anchore/scan-action@1638637db639e0ade3258b51db49a9a137574c3e # v6.5.1 382 | with: 383 | image: ${{ env.DOCKER_CACHE_GRYPE }} 384 | fail-build: ${{ env.WORKFLOW_GRYPE_FAIL_ON_SEVERITY }} 385 | severity-cutoff: ${{ env.WORKFLOW_GRYPE_SEVERITY_CUTOFF }} 386 | output-format: 'sarif' 387 | by-cve: true 388 | cache-db: true 389 | 390 | # OUTPUT CVE REPORT IF SCAN FAILS 391 | - name: container image / scan with grype FAILED 392 | if: env.WORKFLOW_BUILD == 'true' && (failure() || steps.grype.outcome == 'failure') && steps.image-build.outcome == 'success' 393 | uses: anchore/scan-action@1638637db639e0ade3258b51db49a9a137574c3e # v6.5.1 394 | with: 395 | image: ${{ env.DOCKER_CACHE_GRYPE }} 396 | fail-build: false 397 | severity-cutoff: ${{ env.WORKFLOW_GRYPE_SEVERITY_CUTOFF }} 398 | output-format: 'table' 399 | by-cve: true 400 | cache-db: true 401 | 402 | # PUSH IMAGE TO ALL REGISTRIES IF CLEAN 403 | - name: container image / push to registries 404 | id: image-push 405 | if: env.WORKFLOW_BUILD == 'true' 406 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 407 | with: 408 | context: . 409 | no-cache: ${{ env.WORKFLOW_BUILD_NO_CACHE }} 410 | file: ${{ env.DOCKER_IMAGE_DOCKERFILE }} 411 | push: true 412 | sbom: true 413 | provenance: mode=max 414 | platforms: linux/${{ matrix.platform }} 415 | cache-from: type=registry,ref=${{ env.DOCKER_CACHE_REGISTRY }}${{ env.DOCKER_CACHE_NAME }} 416 | cache-to: type=registry,ref=${{ env.DOCKER_CACHE_NAME }},mode=max,compression=zstd,force-compression=true 417 | build-args: | 418 | ${{ env.DOCKER_IMAGE_ARGUMENTS }} 419 | tags: | 420 | ${{ env.DOCKER_IMAGE_TAGS }} 421 | 422 | # CREATE ATTESTATION ARTIFACTS 423 | - name: container image / create attestation artifacts 424 | if: env.WORKFLOW_BUILD == 'true' && steps.image-push.outcome == 'success' 425 | uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 426 | with: 427 | subject-name: docker.io/${{ env.DOCKER_IMAGE_NAME }} 428 | subject-digest: ${{ steps.image-push.outputs.digest }} 429 | push-to-registry: false 430 | 431 | # EXPORT DIGEST 432 | - name: container image / export digest 433 | if: env.WORKFLOW_BUILD == 'true' && steps.image-push.outcome == 'success' 434 | run: | 435 | mkdir -p ${{ runner.temp }}/digests 436 | digest="${{ steps.image-push.outputs.digest }}" 437 | touch "${{ runner.temp }}/digests/${digest#sha256:}" 438 | 439 | # UPLOAD DIGEST 440 | - name: container image / upload 441 | if: env.WORKFLOW_BUILD == 'true' && steps.image-push.outcome == 'success' 442 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 443 | with: 444 | name: digests-linux-${{ env.DOCKER_IMAGE_PLATFORM_SANITIZED }} 445 | path: ${{ runner.temp }}/digests/* 446 | if-no-files-found: error 447 | 448 | 449 | # ╔═════════════════════════════════════════════════════╗ 450 | # ║ CREATE GITHUB RELEASE ║ 451 | # ╚═════════════════════════════════════════════════════╝ 452 | # CREATE RELEASE MARKUP 453 | - name: github release / prepare markdown 454 | if: env.WORKFLOW_CREATE_RELEASE == 'true' && matrix.platform == 'amd64' 455 | id: git-release 456 | uses: 11notes/action-docker-release@v1 457 | 458 | # CREATE GITHUB RELEASE 459 | - name: github release / create 460 | if: env.WORKFLOW_CREATE_RELEASE == 'true' && steps.git-release.outcome == 'success' 461 | uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1.1.4 462 | env: 463 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 464 | with: 465 | tag_name: ${{ github.ref }} 466 | release_name: ${{ github.ref }} 467 | body: ${{ steps.git-release.outputs.release }} 468 | draft: false 469 | prerelease: false 470 | 471 | 472 | # ╔═════════════════════════════════════════════════════╗ 473 | # ║ ║ 474 | # ║ ║ 475 | # ║ MERGE IMAGES INTO SINGLE MANIFEST ║ 476 | # ║ ║ 477 | # ║ ║ 478 | # ╚═════════════════════════════════════════════════════╝ 479 | merge_platform_images: 480 | needs: docker 481 | if: needs.docker.outputs.WORKFLOW_BUILD == 'true' 482 | name: merge platform images to a single manifest 483 | runs-on: ubuntu-latest 484 | strategy: 485 | fail-fast: false 486 | matrix: 487 | registry: [docker.io, ghcr.io, quay.io] 488 | 489 | env: 490 | DOCKER_IMAGE_NAME: ${{ needs.docker.outputs.DOCKER_IMAGE_NAME }} 491 | DOCKER_IMAGE_MERGE_TAGS: ${{ needs.docker.outputs.DOCKER_IMAGE_MERGE_TAGS }} 492 | 493 | permissions: 494 | contents: read 495 | packages: write 496 | attestations: write 497 | id-token: write 498 | 499 | steps: 500 | # ╔═════════════════════════════════════════════════════╗ 501 | # ║ CONTAINER REGISTRY LOGIN ║ 502 | # ╚═════════════════════════════════════════════════════╝ 503 | # DOCKER HUB 504 | - name: docker / login to hub 505 | uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 506 | with: 507 | username: 11notes 508 | password: ${{ secrets.DOCKER_TOKEN }} 509 | 510 | # GITHUB CONTAINER REGISTRY 511 | - name: github / login to ghcr 512 | uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 513 | with: 514 | registry: ghcr.io 515 | username: 11notes 516 | password: ${{ secrets.GITHUB_TOKEN }} 517 | 518 | # REDHAT QUAY 519 | - name: quay / login to quay 520 | uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 521 | with: 522 | registry: quay.io 523 | username: 11notes+github 524 | password: ${{ secrets.QUAY_TOKEN }} 525 | 526 | 527 | # ╔═════════════════════════════════════════════════════╗ 528 | # ║ MERGE PLATFORM IMAGES MANIFEST ║ 529 | # ╚═════════════════════════════════════════════════════╝ 530 | # DOWNLOAD DIGESTS 531 | - name: platform merge / digest 532 | uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 533 | with: 534 | path: ${{ runner.temp }}/digests 535 | pattern: digests-* 536 | merge-multiple: true 537 | 538 | # SETUP BUILDX BUILDER 539 | - name: platform merge / buildx 540 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 541 | 542 | # GET META DATA 543 | - name: platform merge / meta 544 | id: meta 545 | uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 546 | with: 547 | images: ${{ matrix.registry }}/${{ env.DOCKER_IMAGE_NAME }} 548 | tags: | 549 | ${{ env.DOCKER_IMAGE_MERGE_TAGS }} 550 | 551 | # CREATE MANIFEST 552 | - name: platform merge / create manifest and push 553 | working-directory: ${{ runner.temp }}/digests 554 | run: | 555 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 556 | $(printf 'docker.io/${{ env.DOCKER_IMAGE_NAME }}@sha256:%s ' *) 557 | 558 | # INSPECT MANIFEST 559 | - name: platform merge / inspect 560 | run: | 561 | docker buildx imagetools inspect ${{ matrix.registry }}/${{ env.DOCKER_IMAGE_NAME }}:${{ steps.meta.outputs.version }} 562 | 563 | 564 | # ╔═════════════════════════════════════════════════════╗ 565 | # ║ ║ 566 | # ║ ║ 567 | # ║ FINALIZE IMAGE CREATION ║ 568 | # ║ ║ 569 | # ║ ║ 570 | # ╚═════════════════════════════════════════════════════╝ 571 | finally: 572 | if: ${{ always() }} 573 | needs: 574 | - docker 575 | - merge_platform_images 576 | name: finalize image creation 577 | runs-on: ubuntu-latest 578 | 579 | env: 580 | DOCKER_IMAGE_NAME: ${{ needs.docker.outputs.DOCKER_IMAGE_NAME }} 581 | DOCKER_IMAGE_DESCRIPTION: ${{ needs.docker.outputs.DOCKER_IMAGE_DESCRIPTION }} 582 | DOCKER_IMAGE_NAME_AND_VERSION: ${{ needs.docker.outputs.DOCKER_IMAGE_NAME_AND_VERSION }} 583 | DOCKER_IMAGE_ARGUMENTS: ${{ needs.docker.outputs.DOCKER_IMAGE_ARGUMENTS }} 584 | 585 | permissions: 586 | contents: write 587 | 588 | steps: 589 | # ╔═════════════════════════════════════════════════════╗ 590 | # ║ SETUP ENVIRONMENT ║ 591 | # ╚═════════════════════════════════════════════════════╝ 592 | # CHECKOUT ALL DEPTHS (ALL TAGS) 593 | - name: init / checkout 594 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 595 | with: 596 | ref: master 597 | fetch-depth: 0 598 | 599 | # ╔═════════════════════════════════════════════════════╗ 600 | # ║ CONTAINER REGISTRY LOGIN ║ 601 | # ╚═════════════════════════════════════════════════════╝ 602 | # DOCKER HUB 603 | - name: docker / login to hub 604 | uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 605 | with: 606 | username: 11notes 607 | password: ${{ secrets.DOCKER_TOKEN }} 608 | 609 | # ╔═════════════════════════════════════════════════════╗ 610 | # ║ CREATE README.md ║ 611 | # ╚═════════════════════════════════════════════════════╝ 612 | # CHECKOUT HEAD TO BE UP TO DATE WITH EVERYTHING 613 | - name: README.md / checkout 614 | if: github.event.inputs.readme == 'true' 615 | continue-on-error: true 616 | run: | 617 | git checkout HEAD 618 | 619 | # CREATE MAKRDOWN OF README.md 620 | - name: README.md / create 621 | if: github.event.inputs.readme == 'true' 622 | id: github-readme 623 | continue-on-error: true 624 | uses: 11notes/action-docker-readme@v1 625 | 626 | # UPLOAD README.md to DOCKER HUB 627 | - name: README.md / push to Docker Hub 628 | if: github.event.inputs.readme == 'true' && steps.github-readme.outcome == 'success' && hashFiles('README_NONGITHUB.md') != '' 629 | continue-on-error: true 630 | uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8 # v1 631 | env: 632 | DOCKER_USER: 11notes 633 | DOCKER_PASS: ${{ secrets.DOCKER_TOKEN }} 634 | with: 635 | destination_container_repo: ${{ env.DOCKER_IMAGE_NAME }} 636 | provider: dockerhub 637 | short_description: ${{ env.DOCKER_IMAGE_DESCRIPTION }} 638 | readme_file: 'README_NONGITHUB.md' 639 | 640 | # COMMIT NEW README.md, LICENSE and compose 641 | - name: README.md / github commit & push 642 | if: github.event.inputs.readme == 'true' && steps.github-readme.outcome == 'success' && hashFiles('README.md') != '' 643 | continue-on-error: true 644 | run: | 645 | git config user.name "github-actions[bot]" 646 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 647 | git add README.md 648 | if [ -f compose.yaml ]; then 649 | git add compose.yaml 650 | fi 651 | if [ -f compose.yml ]; then 652 | git add compose.yml 653 | fi 654 | if [ -f LICENSE ]; then 655 | git add LICENSE 656 | fi 657 | git commit -m "update README.md" 658 | git push origin HEAD:master 659 | 660 | # ╔═════════════════════════════════════════════════════╗ 661 | # ║ GITHUB REPOSITORY DEFAULT SETTINGS ║ 662 | # ╚═════════════════════════════════════════════════════╝ 663 | # UPDATE REPO WITH DEFAULT SETTINGS FOR CONTAINER IMAGE 664 | - name: github / update description and set repo defaults 665 | run: | 666 | curl --request PATCH \ 667 | --url https://api.github.com/repos/${{ github.repository }} \ 668 | --header 'authorization: Bearer ${{ secrets.REPOSITORY_TOKEN }}' \ 669 | --header 'content-type: application/json' \ 670 | --data '{ 671 | "description":"${{ env.DOCKER_IMAGE_DESCRIPTION }}", 672 | "homepage":"", 673 | "has_issues":true, 674 | "has_discussions":true, 675 | "has_projects":false, 676 | "has_wiki":false 677 | }' \ 678 | --fail --------------------------------------------------------------------------------