[{"data":1,"prerenderedAt":1632},["ShallowReactive",2],{"nav-categories":3,"tag-mtls":8},{"pinned":4,"overflow":7},[5,6],"Technology","Security",[],[9],{"id":10,"title":11,"body":12,"category":6,"date":1617,"description":1618,"extension":1619,"image":1620,"imageCredit":1621,"imageCreditUrl":1621,"meta":1622,"navigation":82,"path":1623,"public":82,"seo":1624,"stem":1625,"tags":1626,"__hash__":1631},"posts\u002Fblog\u002Fsecurity\u002Fself-hosted-container-registry-mtls.md","Self-hosting a private container registry with NGINX and mTLS",{"type":13,"value":14,"toc":1591},"minimark",[15,24,31,36,42,120,124,127,134,138,143,146,154,158,168,472,475,479,492,509,515,701,704,715,750,754,761,894,909,915,919,922,928,932,935,988,991,995,998,1114,1121,1125,1132,1166,1184,1188,1198,1213,1219,1257,1270,1273,1277,1281,1290,1294,1300,1354,1368,1372,1381,1391,1395,1398,1425,1428,1443,1447,1450,1501,1505,1508,1528,1534,1538,1541,1546,1549,1557,1577,1581,1584,1587],[16,17,18,19,23],"p",{},"Public container registries like Docker Hub and GitHub Container Registry are\nconvenient, but there are good reasons to run your own. Maybe you want full\ncontrol over where your images live. Maybe you are running services on a home\nserver and would rather not push proprietary application images to a third\nparty. Or perhaps you simply want to understand how the registry protocol works\nbeneath the surface of ",[20,21,22],"code",{},"docker push",".",[16,25,26,27,30],{},"This article walks through setting up a private Docker container registry from\nscratch, fronted by NGINX for TLS termination, secured with ",[20,28,29],{},"htpasswd","\ncredentials, and locked down with mutual TLS (mTLS) so that only machines\nholding a valid client certificate can connect at all. By the end, you will have\na working registry that you can push to and pull from, with two layers of\nauthentication protecting it.",[32,33,35],"h2",{"id":34},"what-we-are-building","What We Are Building",[16,37,38,39,41],{},"The architecture is straightforward. NGINX sits in front of the registry and\nhandles TLS termination, so the registry itself runs plain HTTP internally. When\na Docker client connects, NGINX first verifies the client's TLS certificate\nagainst a private certificate authority. If the certificate is valid, the\nrequest is forwarded to the registry, which then checks ",[20,40,29],{}," credentials\nvia HTTP Basic Auth. Both checks must pass before any image data moves.",[43,44,49],"pre",{"className":45,"code":46,"language":47,"meta":48,"style":48},"language-mermaid shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","sequenceDiagram\n    participant Client as Docker Client\n    participant NGINX as NGINX (TLS + mTLS)\n    participant Registry as registry:5000\n\n    Client->>NGINX: TLS handshake + client cert\n    NGINX->>NGINX: Verify client cert against CA\n    NGINX->>Registry: Proxy HTTP request\n    Registry->>Registry: Verify htpasswd credentials\n    Registry-->>NGINX: 200 OK \u002F image data\n    NGINX-->>Client: Response\n","mermaid","",[20,50,51,59,65,71,77,84,90,96,102,108,114],{"__ignoreMap":48},[52,53,56],"span",{"class":54,"line":55},"line",1,[52,57,58],{},"sequenceDiagram\n",[52,60,62],{"class":54,"line":61},2,[52,63,64],{},"    participant Client as Docker Client\n",[52,66,68],{"class":54,"line":67},3,[52,69,70],{},"    participant NGINX as NGINX (TLS + mTLS)\n",[52,72,74],{"class":54,"line":73},4,[52,75,76],{},"    participant Registry as registry:5000\n",[52,78,80],{"class":54,"line":79},5,[52,81,83],{"emptyLinePlaceholder":82},true,"\n",[52,85,87],{"class":54,"line":86},6,[52,88,89],{},"    Client->>NGINX: TLS handshake + client cert\n",[52,91,93],{"class":54,"line":92},7,[52,94,95],{},"    NGINX->>NGINX: Verify client cert against CA\n",[52,97,99],{"class":54,"line":98},8,[52,100,101],{},"    NGINX->>Registry: Proxy HTTP request\n",[52,103,105],{"class":54,"line":104},9,[52,106,107],{},"    Registry->>Registry: Verify htpasswd credentials\n",[52,109,111],{"class":54,"line":110},10,[52,112,113],{},"    Registry-->>NGINX: 200 OK \u002F image data\n",[52,115,117],{"class":54,"line":116},11,[52,118,119],{},"    NGINX-->>Client: Response\n",[32,121,123],{"id":122},"prerequisites","Prerequisites",[16,125,126],{},"You will need Docker and Docker Compose installed on the machine that will host\nthe registry. You also need a domain name pointing to that machine with a valid\nTLS certificate for NGINX. If you are running this on a home server behind a\nrouter, you will need to forward ports 80 and 443 to the host. This guide\nassumes you already have TLS certificates in hand (from Let's Encrypt or\notherwise) and focuses on the registry and mTLS setup.",[16,128,129,130,133],{},"You will also need ",[20,131,132],{},"openssl"," installed on your machine to generate the CA and\nclient certificates. It ships with most Linux distributions and macOS.",[32,135,137],{"id":136},"setting-up-the-registry","Setting Up the Registry",[139,140,142],"h3",{"id":141},"project-structure","Project Structure",[16,144,145],{},"Start with a clean directory. By the end of this section, the layout will look\nlike this:",[43,147,152],{"className":148,"code":150,"language":151},[149],"language-text","registry-server\u002F\n├── bin\u002F\n│   └── registry-entrypoint\n├── nginx\u002F\n│   └── registry.conf\n├── pki\u002F\n│   └── mtls\u002F\n│       ├── ca\u002F\n│       └── clients\u002F\n├── registry\u002F\n│   └── data\u002F\n├── docker-compose.yml\n└── .env\n","text",[20,153,150],{"__ignoreMap":48},[139,155,157],{"id":156},"docker-compose","Docker Compose",[16,159,160,161,164,165,167],{},"The compose file defines two services. The registry runs the official\n",[20,162,163],{},"registry:latest"," image with ",[20,166,29],{}," authentication enabled via environment\nvariables. NGINX sits in front and proxies HTTPS traffic to the registry's\ninternal HTTP port.",[43,169,173],{"className":170,"code":171,"language":172,"meta":48,"style":48},"language-yaml shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","services:\n  registry:\n    image: registry:latest\n    container_name: registry\n    restart: always\n    env_file:\n      - .env\n    environment:\n      REGISTRY_AUTH: htpasswd\n      REGISTRY_AUTH_HTPASSWD_REALM: Registry Realm\n      REGISTRY_AUTH_HTPASSWD_PATH: \u002Fauth\u002Fregistry.password\n      REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: \u002Fdata\n    volumes:\n      - .\u002Fregistry\u002Fdata:\u002Fdata\n      - .\u002Fbin\u002Fregistry-entrypoint:\u002Fregistry-entrypoint:ro\n    entrypoint: [\"\u002Fbin\u002Fsh\", \"\u002Fregistry-entrypoint\"]\n    ports:\n      - \"5000\"\n\n  nginx:\n    image: nginx:latest\n    container_name: nginx\n    restart: always\n    depends_on:\n      - registry\n    ports:\n      - \"443:443\"\n    volumes:\n      - .\u002Fnginx\u002Fregistry.conf:\u002Fetc\u002Fnginx\u002Fconf.d\u002Fdefault.conf:ro\n      - .\u002Fcerts:\u002Fetc\u002Fnginx\u002Fcerts:ro\n      - .\u002Fpki\u002Fmtls\u002Fca\u002Fca.crt:\u002Fetc\u002Fnginx\u002Fmtls\u002Fca.crt:ro\n","yaml",[20,174,175,185,192,204,214,224,231,239,246,256,266,276,287,295,303,311,344,352,365,370,378,388,398,407,415,422,429,441,448,456,464],{"__ignoreMap":48},[52,176,177,181],{"class":54,"line":55},[52,178,180],{"class":179},"swJcz","services",[52,182,184],{"class":183},"sMK4o",":\n",[52,186,187,190],{"class":54,"line":61},[52,188,189],{"class":179},"  registry",[52,191,184],{"class":183},[52,193,194,197,200],{"class":54,"line":67},[52,195,196],{"class":179},"    image",[52,198,199],{"class":183},":",[52,201,203],{"class":202},"sfazB"," registry:latest\n",[52,205,206,209,211],{"class":54,"line":73},[52,207,208],{"class":179},"    container_name",[52,210,199],{"class":183},[52,212,213],{"class":202}," registry\n",[52,215,216,219,221],{"class":54,"line":79},[52,217,218],{"class":179},"    restart",[52,220,199],{"class":183},[52,222,223],{"class":202}," always\n",[52,225,226,229],{"class":54,"line":86},[52,227,228],{"class":179},"    env_file",[52,230,184],{"class":183},[52,232,233,236],{"class":54,"line":92},[52,234,235],{"class":183},"      -",[52,237,238],{"class":202}," .env\n",[52,240,241,244],{"class":54,"line":98},[52,242,243],{"class":179},"    environment",[52,245,184],{"class":183},[52,247,248,251,253],{"class":54,"line":104},[52,249,250],{"class":179},"      REGISTRY_AUTH",[52,252,199],{"class":183},[52,254,255],{"class":202}," htpasswd\n",[52,257,258,261,263],{"class":54,"line":110},[52,259,260],{"class":179},"      REGISTRY_AUTH_HTPASSWD_REALM",[52,262,199],{"class":183},[52,264,265],{"class":202}," Registry Realm\n",[52,267,268,271,273],{"class":54,"line":116},[52,269,270],{"class":179},"      REGISTRY_AUTH_HTPASSWD_PATH",[52,272,199],{"class":183},[52,274,275],{"class":202}," \u002Fauth\u002Fregistry.password\n",[52,277,279,282,284],{"class":54,"line":278},12,[52,280,281],{"class":179},"      REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY",[52,283,199],{"class":183},[52,285,286],{"class":202}," \u002Fdata\n",[52,288,290,293],{"class":54,"line":289},13,[52,291,292],{"class":179},"    volumes",[52,294,184],{"class":183},[52,296,298,300],{"class":54,"line":297},14,[52,299,235],{"class":183},[52,301,302],{"class":202}," .\u002Fregistry\u002Fdata:\u002Fdata\n",[52,304,306,308],{"class":54,"line":305},15,[52,307,235],{"class":183},[52,309,310],{"class":202}," .\u002Fbin\u002Fregistry-entrypoint:\u002Fregistry-entrypoint:ro\n",[52,312,314,317,319,322,325,328,330,333,336,339,341],{"class":54,"line":313},16,[52,315,316],{"class":179},"    entrypoint",[52,318,199],{"class":183},[52,320,321],{"class":183}," [",[52,323,324],{"class":183},"\"",[52,326,327],{"class":202},"\u002Fbin\u002Fsh",[52,329,324],{"class":183},[52,331,332],{"class":183},",",[52,334,335],{"class":183}," \"",[52,337,338],{"class":202},"\u002Fregistry-entrypoint",[52,340,324],{"class":183},[52,342,343],{"class":183},"]\n",[52,345,347,350],{"class":54,"line":346},17,[52,348,349],{"class":179},"    ports",[52,351,184],{"class":183},[52,353,355,357,359,362],{"class":54,"line":354},18,[52,356,235],{"class":183},[52,358,335],{"class":183},[52,360,361],{"class":202},"5000",[52,363,364],{"class":183},"\"\n",[52,366,368],{"class":54,"line":367},19,[52,369,83],{"emptyLinePlaceholder":82},[52,371,373,376],{"class":54,"line":372},20,[52,374,375],{"class":179},"  nginx",[52,377,184],{"class":183},[52,379,381,383,385],{"class":54,"line":380},21,[52,382,196],{"class":179},[52,384,199],{"class":183},[52,386,387],{"class":202}," nginx:latest\n",[52,389,391,393,395],{"class":54,"line":390},22,[52,392,208],{"class":179},[52,394,199],{"class":183},[52,396,397],{"class":202}," nginx\n",[52,399,401,403,405],{"class":54,"line":400},23,[52,402,218],{"class":179},[52,404,199],{"class":183},[52,406,223],{"class":202},[52,408,410,413],{"class":54,"line":409},24,[52,411,412],{"class":179},"    depends_on",[52,414,184],{"class":183},[52,416,418,420],{"class":54,"line":417},25,[52,419,235],{"class":183},[52,421,213],{"class":202},[52,423,425,427],{"class":54,"line":424},26,[52,426,349],{"class":179},[52,428,184],{"class":183},[52,430,432,434,436,439],{"class":54,"line":431},27,[52,433,235],{"class":183},[52,435,335],{"class":183},[52,437,438],{"class":202},"443:443",[52,440,364],{"class":183},[52,442,444,446],{"class":54,"line":443},28,[52,445,292],{"class":179},[52,447,184],{"class":183},[52,449,451,453],{"class":54,"line":450},29,[52,452,235],{"class":183},[52,454,455],{"class":202}," .\u002Fnginx\u002Fregistry.conf:\u002Fetc\u002Fnginx\u002Fconf.d\u002Fdefault.conf:ro\n",[52,457,459,461],{"class":54,"line":458},30,[52,460,235],{"class":183},[52,462,463],{"class":202}," .\u002Fcerts:\u002Fetc\u002Fnginx\u002Fcerts:ro\n",[52,465,467,469],{"class":54,"line":466},31,[52,468,235],{"class":183},[52,470,471],{"class":202}," .\u002Fpki\u002Fmtls\u002Fca\u002Fca.crt:\u002Fetc\u002Fnginx\u002Fmtls\u002Fca.crt:ro\n",[16,473,474],{},"The registry does not expose any ports to the host directly. Only NGINX is\nreachable from the outside, and only on port 443.",[139,476,478],{"id":477},"registry-credentials","Registry Credentials",[16,480,481,482,484,485,487,488,491],{},"The registry image uses ",[20,483,29],{}," files for authentication, but it does not\nship with the ",[20,486,29],{}," binary. Rather than baking credentials into an image at\nbuild time, we use an entrypoint script that installs the utility and generates\nthe password file from environment variables at startup. This keeps credentials\nout of your image layers and lets you manage them through a ",[20,489,490],{},".env"," file.",[43,493,497],{"className":494,"code":495,"language":496,"meta":48,"style":48},"language-ini shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","REGISTRY_USER=deployer\nREGISTRY_PASSWORD=your-strong-password-here\n","ini",[20,498,499,504],{"__ignoreMap":48},[52,500,501],{"class":54,"line":55},[52,502,503],{},"REGISTRY_USER=deployer\n",[52,505,506],{"class":54,"line":61},[52,507,508],{},"REGISTRY_PASSWORD=your-strong-password-here\n",[16,510,511,512,514],{},"The entrypoint script reads these variables and creates the ",[20,513,29],{}," file\nbefore handing off to the registry's default entrypoint.",[43,516,520],{"className":517,"code":518,"language":519,"meta":48,"style":48},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","#!\u002Fusr\u002Fbin\u002Fenv bash\n\nset -euo pipefail\n\nif [ -z \"${REGISTRY_USER:-}\" ] || [ -z \"${REGISTRY_PASSWORD:-}\" ]; then\n    echo \"ERROR: REGISTRY_USER and REGISTRY_PASSWORD must be set\"\n    exit 1\nfi\n\napk add --no-cache apache2-utils > \u002Fdev\u002Fnull 2>&1\n\nmkdir -p \u002Fauth\nhtpasswd -Bbn \"$REGISTRY_USER\" \"$REGISTRY_PASSWORD\" > \u002Fauth\u002Fregistry.password\n\nexec \u002Fentrypoint.sh \u002Fetc\u002Fdistribution\u002Fconfig.yml\n","bash",[20,521,522,528,532,544,548,592,604,613,618,622,646,650,661,686,690],{"__ignoreMap":48},[52,523,524],{"class":54,"line":55},[52,525,527],{"class":526},"sHwdD","#!\u002Fusr\u002Fbin\u002Fenv bash\n",[52,529,530],{"class":54,"line":61},[52,531,83],{"emptyLinePlaceholder":82},[52,533,534,538,541],{"class":54,"line":67},[52,535,537],{"class":536},"s2Zo4","set",[52,539,540],{"class":202}," -euo",[52,542,543],{"class":202}," pipefail\n",[52,545,546],{"class":54,"line":73},[52,547,83],{"emptyLinePlaceholder":82},[52,549,550,554,556,559,562,566,569,572,575,577,579,581,584,586,589],{"class":54,"line":79},[52,551,553],{"class":552},"s7zQu","if",[52,555,321],{"class":183},[52,557,558],{"class":183}," -z",[52,560,561],{"class":183}," \"${",[52,563,565],{"class":564},"sTEyZ","REGISTRY_USER",[52,567,568],{"class":183},":-}\"",[52,570,571],{"class":183}," ]",[52,573,574],{"class":183}," ||",[52,576,321],{"class":183},[52,578,558],{"class":183},[52,580,561],{"class":183},[52,582,583],{"class":564},"REGISTRY_PASSWORD",[52,585,568],{"class":183},[52,587,588],{"class":183}," ];",[52,590,591],{"class":552}," then\n",[52,593,594,597,599,602],{"class":54,"line":86},[52,595,596],{"class":536},"    echo",[52,598,335],{"class":183},[52,600,601],{"class":202},"ERROR: REGISTRY_USER and REGISTRY_PASSWORD must be set",[52,603,364],{"class":183},[52,605,606,609],{"class":54,"line":92},[52,607,608],{"class":536},"    exit",[52,610,612],{"class":611},"sbssI"," 1\n",[52,614,615],{"class":54,"line":98},[52,616,617],{"class":552},"fi\n",[52,619,620],{"class":54,"line":104},[52,621,83],{"emptyLinePlaceholder":82},[52,623,624,628,631,634,637,640,643],{"class":54,"line":110},[52,625,627],{"class":626},"sBMFI","apk",[52,629,630],{"class":202}," add",[52,632,633],{"class":202}," --no-cache",[52,635,636],{"class":202}," apache2-utils",[52,638,639],{"class":183}," >",[52,641,642],{"class":202}," \u002Fdev\u002Fnull",[52,644,645],{"class":183}," 2>&1\n",[52,647,648],{"class":54,"line":116},[52,649,83],{"emptyLinePlaceholder":82},[52,651,652,655,658],{"class":54,"line":278},[52,653,654],{"class":626},"mkdir",[52,656,657],{"class":202}," -p",[52,659,660],{"class":202}," \u002Fauth\n",[52,662,663,665,668,670,673,675,677,680,682,684],{"class":54,"line":289},[52,664,29],{"class":626},[52,666,667],{"class":202}," -Bbn",[52,669,335],{"class":183},[52,671,672],{"class":564},"$REGISTRY_USER",[52,674,324],{"class":183},[52,676,335],{"class":183},[52,678,679],{"class":564},"$REGISTRY_PASSWORD",[52,681,324],{"class":183},[52,683,639],{"class":183},[52,685,275],{"class":202},[52,687,688],{"class":54,"line":297},[52,689,83],{"emptyLinePlaceholder":82},[52,691,692,695,698],{"class":54,"line":305},[52,693,694],{"class":536},"exec",[52,696,697],{"class":202}," \u002Fentrypoint.sh",[52,699,700],{"class":202}," \u002Fetc\u002Fdistribution\u002Fconfig.yml\n",[16,702,703],{},"Make it executable.",[43,705,709],{"className":706,"code":707,"language":708,"meta":48,"style":48},"language-sh shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","chmod +x bin\u002Fregistry-entrypoint\n","sh",[20,710,711],{"__ignoreMap":48},[52,712,713],{"class":54,"line":55},[52,714,707],{},[16,716,717,718,720,721,723,724,727,728,730,731,734,735,738,739,742,743,746,747,749],{},"A couple of details worth noting here. The ",[20,719,163],{}," image is\nAlpine-based, so ",[20,722,627],{}," is the package manager. The ",[20,725,726],{},"-Bbn"," flags tell ",[20,729,29],{},"\nto use ",[20,732,733],{},"bcrypt"," hashing (",[20,736,737],{},"-B","), run in batch mode (",[20,740,741],{},"-b","), and write to stdout\n(",[20,744,745],{},"-n",") which we redirect to the file. The final ",[20,748,694],{}," replaces the shell\nprocess with the actual registry binary, so signals like SIGTERM are forwarded\ncorrectly when Docker stops the container.",[139,751,753],{"id":752},"nginx-configuration","NGINX Configuration",[16,755,756,757,760],{},"The NGINX configuration proxies HTTPS traffic to the registry. Replace\n",[20,758,759],{},"registry.example.com"," with your actual domain and adjust the certificate paths\nto match your setup.",[43,762,766],{"className":763,"code":764,"language":765,"meta":48,"style":48},"language-nginx shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","server {\n    listen 443 ssl;\n    listen [::]:443 ssl;\n    http2 on;\n    server_name registry.example.com;\n\n    ssl_certificate \u002Fetc\u002Fnginx\u002Fcerts\u002Ffullchain.pem;\n    ssl_certificate_key \u002Fetc\u002Fnginx\u002Fcerts\u002Fprivkey.pem;\n    ssl_protocols TLSv1.2 TLSv1.3;\n\n    # mTLS: require a valid client certificate\n    ssl_client_certificate \u002Fetc\u002Fnginx\u002Fmtls\u002Fca.crt;\n    ssl_verify_client on;\n\n    # Container images can be large\n    client_max_body_size 2g;\n\n    location \u002F {\n        proxy_pass http:\u002F\u002Fregistry:5000;\n        proxy_set_header Host $http_host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_read_timeout 900;\n    }\n}\n","nginx",[20,767,768,773,778,783,788,793,797,802,807,812,816,821,826,831,835,840,845,849,854,859,864,869,874,879,884,889],{"__ignoreMap":48},[52,769,770],{"class":54,"line":55},[52,771,772],{},"server {\n",[52,774,775],{"class":54,"line":61},[52,776,777],{},"    listen 443 ssl;\n",[52,779,780],{"class":54,"line":67},[52,781,782],{},"    listen [::]:443 ssl;\n",[52,784,785],{"class":54,"line":73},[52,786,787],{},"    http2 on;\n",[52,789,790],{"class":54,"line":79},[52,791,792],{},"    server_name registry.example.com;\n",[52,794,795],{"class":54,"line":86},[52,796,83],{"emptyLinePlaceholder":82},[52,798,799],{"class":54,"line":92},[52,800,801],{},"    ssl_certificate \u002Fetc\u002Fnginx\u002Fcerts\u002Ffullchain.pem;\n",[52,803,804],{"class":54,"line":98},[52,805,806],{},"    ssl_certificate_key \u002Fetc\u002Fnginx\u002Fcerts\u002Fprivkey.pem;\n",[52,808,809],{"class":54,"line":104},[52,810,811],{},"    ssl_protocols TLSv1.2 TLSv1.3;\n",[52,813,814],{"class":54,"line":110},[52,815,83],{"emptyLinePlaceholder":82},[52,817,818],{"class":54,"line":116},[52,819,820],{},"    # mTLS: require a valid client certificate\n",[52,822,823],{"class":54,"line":278},[52,824,825],{},"    ssl_client_certificate \u002Fetc\u002Fnginx\u002Fmtls\u002Fca.crt;\n",[52,827,828],{"class":54,"line":289},[52,829,830],{},"    ssl_verify_client on;\n",[52,832,833],{"class":54,"line":297},[52,834,83],{"emptyLinePlaceholder":82},[52,836,837],{"class":54,"line":305},[52,838,839],{},"    # Container images can be large\n",[52,841,842],{"class":54,"line":313},[52,843,844],{},"    client_max_body_size 2g;\n",[52,846,847],{"class":54,"line":346},[52,848,83],{"emptyLinePlaceholder":82},[52,850,851],{"class":54,"line":354},[52,852,853],{},"    location \u002F {\n",[52,855,856],{"class":54,"line":367},[52,857,858],{},"        proxy_pass http:\u002F\u002Fregistry:5000;\n",[52,860,861],{"class":54,"line":372},[52,862,863],{},"        proxy_set_header Host $http_host;\n",[52,865,866],{"class":54,"line":380},[52,867,868],{},"        proxy_set_header X-Real-IP $remote_addr;\n",[52,870,871],{"class":54,"line":390},[52,872,873],{},"        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n",[52,875,876],{"class":54,"line":400},[52,877,878],{},"        proxy_set_header X-Forwarded-Proto $scheme;\n",[52,880,881],{"class":54,"line":409},[52,882,883],{},"        proxy_read_timeout 900;\n",[52,885,886],{"class":54,"line":417},[52,887,888],{},"    }\n",[52,890,891],{"class":54,"line":424},[52,892,893],{},"}\n",[16,895,896,897,900,901,904,905,908],{},"The ",[20,898,899],{},"client_max_body_size 2g"," directive is important. Container images are\ntransferred as compressed layers, and individual layers can be hundreds of\nmegabytes. Without this directive, NGINX defaults to a ",[20,902,903],{},"1MB"," body limit and will\nreject most pushes with a ",[20,906,907],{},"413 Request Entity Too Large"," error.",[16,910,896,911,914],{},[20,912,913],{},"proxy_read_timeout 900"," gives the registry 15 minutes to respond, which\naccommodates large layer uploads on slow connections.",[32,916,918],{"id":917},"building-the-mtls-layer","Building the mTLS Layer",[16,920,921],{},"Mutual TLS adds a second dimension to the TLS handshake. In standard TLS, only\nthe server presents a certificate and the client verifies it. In mTLS, the\nclient also presents a certificate, and the server verifies it against a trusted\ncertificate authority. If the client cannot produce a valid certificate, the\nconnection is refused before any HTTP traffic is exchanged.",[16,923,924,925,927],{},"This is a fundamentally different security boundary from ",[20,926,29],{},". Credentials\ncan be phished, leaked, or brute-forced. A client certificate is a cryptographic\nproof tied to a private key that never leaves the machine. An attacker would\nneed physical or root access to the machine to extract it.",[139,929,931],{"id":930},"creating-a-private-certificate-authority","Creating a Private Certificate Authority",[16,933,934],{},"The CA is the root of trust for the entire mTLS setup. Any client certificate\nsigned by this CA will be accepted by NGINX. Keep the CA private key secure\nsince anyone with access to it can mint new client certificates.",[43,936,938],{"className":706,"code":937,"language":708,"meta":48,"style":48},"mkdir -p pki\u002Fmtls\u002Fca\n\n# Generate the CA private key (4096-bit RSA)\nopenssl genrsa -out pki\u002Fmtls\u002Fca\u002Fca.key 4096\n\n# Generate the CA certificate (valid for 10 years)\nopenssl req -new -x509 -days 3650 \\\n    -key pki\u002Fmtls\u002Fca\u002Fca.key \\\n    -out pki\u002Fmtls\u002Fca\u002Fca.crt \\\n    -subj \"\u002FCN=Registry mTLS CA\u002FO=example.com\"\n",[20,939,940,945,949,954,959,963,968,973,978,983],{"__ignoreMap":48},[52,941,942],{"class":54,"line":55},[52,943,944],{},"mkdir -p pki\u002Fmtls\u002Fca\n",[52,946,947],{"class":54,"line":61},[52,948,83],{"emptyLinePlaceholder":82},[52,950,951],{"class":54,"line":67},[52,952,953],{},"# Generate the CA private key (4096-bit RSA)\n",[52,955,956],{"class":54,"line":73},[52,957,958],{},"openssl genrsa -out pki\u002Fmtls\u002Fca\u002Fca.key 4096\n",[52,960,961],{"class":54,"line":79},[52,962,83],{"emptyLinePlaceholder":82},[52,964,965],{"class":54,"line":86},[52,966,967],{},"# Generate the CA certificate (valid for 10 years)\n",[52,969,970],{"class":54,"line":92},[52,971,972],{},"openssl req -new -x509 -days 3650 \\\n",[52,974,975],{"class":54,"line":98},[52,976,977],{},"    -key pki\u002Fmtls\u002Fca\u002Fca.key \\\n",[52,979,980],{"class":54,"line":104},[52,981,982],{},"    -out pki\u002Fmtls\u002Fca\u002Fca.crt \\\n",[52,984,985],{"class":54,"line":110},[52,986,987],{},"    -subj \"\u002FCN=Registry mTLS CA\u002FO=example.com\"\n",[16,989,990],{},"The 10-year validity is intentional. Rotating a CA means re-issuing every client\ncertificate it has signed, so a long lifetime avoids unnecessary churn for an\ninternal-only CA. This is different from server certificates where short\nlifetimes (90 days with Let's Encrypt) are standard practice, because server\ncertificates are tied to public domain identity and subject to revocation list\nchecking.",[139,992,994],{"id":993},"generating-client-certificates","Generating Client Certificates",[16,996,997],{},"Each machine that needs access to the registry gets its own client certificate,\nsigned by the CA. This gives you fine-grained control. If a machine is\ncompromised, you revoke that one certificate without affecting others.",[43,999,1001],{"className":706,"code":1000,"language":708,"meta":48,"style":48},"CLIENT_NAME=\"desktop\"\nCLIENT_DIR=\"pki\u002Fmtls\u002Fclients\u002F$CLIENT_NAME\"\nmkdir -p \"$CLIENT_DIR\"\n\n# Generate client private key\nopenssl genrsa -out \"$CLIENT_DIR\u002Fclient.key\" 4096\n\n# Generate a certificate signing request\nopenssl req -new \\\n    -key \"$CLIENT_DIR\u002Fclient.key\" \\\n    -out \"$CLIENT_DIR\u002Fclient.csr\" \\\n    -subj \"\u002FCN=$CLIENT_NAME\u002FO=example.com\"\n\n# Sign it with the CA\nopenssl x509 -req -days 3650 \\\n    -in \"$CLIENT_DIR\u002Fclient.csr\" \\\n    -CA pki\u002Fmtls\u002Fca\u002Fca.crt \\\n    -CAkey pki\u002Fmtls\u002Fca\u002Fca.key \\\n    -CAcreateserial \\\n    -out \"$CLIENT_DIR\u002Fclient.cert\"\n\n# Clean up the CSR\nrm \"$CLIENT_DIR\u002Fclient.csr\"\n",[20,1002,1003,1008,1013,1018,1022,1027,1032,1036,1041,1046,1051,1056,1061,1065,1070,1075,1080,1085,1090,1095,1100,1104,1109],{"__ignoreMap":48},[52,1004,1005],{"class":54,"line":55},[52,1006,1007],{},"CLIENT_NAME=\"desktop\"\n",[52,1009,1010],{"class":54,"line":61},[52,1011,1012],{},"CLIENT_DIR=\"pki\u002Fmtls\u002Fclients\u002F$CLIENT_NAME\"\n",[52,1014,1015],{"class":54,"line":67},[52,1016,1017],{},"mkdir -p \"$CLIENT_DIR\"\n",[52,1019,1020],{"class":54,"line":73},[52,1021,83],{"emptyLinePlaceholder":82},[52,1023,1024],{"class":54,"line":79},[52,1025,1026],{},"# Generate client private key\n",[52,1028,1029],{"class":54,"line":86},[52,1030,1031],{},"openssl genrsa -out \"$CLIENT_DIR\u002Fclient.key\" 4096\n",[52,1033,1034],{"class":54,"line":92},[52,1035,83],{"emptyLinePlaceholder":82},[52,1037,1038],{"class":54,"line":98},[52,1039,1040],{},"# Generate a certificate signing request\n",[52,1042,1043],{"class":54,"line":104},[52,1044,1045],{},"openssl req -new \\\n",[52,1047,1048],{"class":54,"line":110},[52,1049,1050],{},"    -key \"$CLIENT_DIR\u002Fclient.key\" \\\n",[52,1052,1053],{"class":54,"line":116},[52,1054,1055],{},"    -out \"$CLIENT_DIR\u002Fclient.csr\" \\\n",[52,1057,1058],{"class":54,"line":278},[52,1059,1060],{},"    -subj \"\u002FCN=$CLIENT_NAME\u002FO=example.com\"\n",[52,1062,1063],{"class":54,"line":289},[52,1064,83],{"emptyLinePlaceholder":82},[52,1066,1067],{"class":54,"line":297},[52,1068,1069],{},"# Sign it with the CA\n",[52,1071,1072],{"class":54,"line":305},[52,1073,1074],{},"openssl x509 -req -days 3650 \\\n",[52,1076,1077],{"class":54,"line":313},[52,1078,1079],{},"    -in \"$CLIENT_DIR\u002Fclient.csr\" \\\n",[52,1081,1082],{"class":54,"line":346},[52,1083,1084],{},"    -CA pki\u002Fmtls\u002Fca\u002Fca.crt \\\n",[52,1086,1087],{"class":54,"line":354},[52,1088,1089],{},"    -CAkey pki\u002Fmtls\u002Fca\u002Fca.key \\\n",[52,1091,1092],{"class":54,"line":367},[52,1093,1094],{},"    -CAcreateserial \\\n",[52,1096,1097],{"class":54,"line":372},[52,1098,1099],{},"    -out \"$CLIENT_DIR\u002Fclient.cert\"\n",[52,1101,1102],{"class":54,"line":380},[52,1103,83],{"emptyLinePlaceholder":82},[52,1105,1106],{"class":54,"line":390},[52,1107,1108],{},"# Clean up the CSR\n",[52,1110,1111],{"class":54,"line":400},[52,1112,1113],{},"rm \"$CLIENT_DIR\u002Fclient.csr\"\n",[16,1115,1116,1117,1120],{},"Repeat this for every machine that needs registry access, changing the\n",[20,1118,1119],{},"CLIENT_NAME"," each time.",[139,1122,1124],{"id":1123},"installing-client-certificates-for-docker","Installing Client Certificates for Docker",[16,1126,1127,1128,1131],{},"Docker looks for client certificates in a specific directory structure. On Linux\nwith the native Docker Engine, this is ",[20,1129,1130],{},"\u002Fetc\u002Fdocker\u002Fcerts.d\u002F",". The directory\nname must match the registry hostname exactly.",[43,1133,1135],{"className":706,"code":1134,"language":708,"meta":48,"style":48},"REGISTRY_HOST=\"registry.example.com\"\n\nsudo mkdir -p \"\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\"\nsudo cp pki\u002Fmtls\u002Fca\u002Fca.crt \"\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\u002Fca.crt\"\nsudo cp pki\u002Fmtls\u002Fclients\u002Fdesktop\u002Fclient.cert \"\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\u002Fclient.cert\"\nsudo cp pki\u002Fmtls\u002Fclients\u002Fdesktop\u002Fclient.key \"\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\u002Fclient.key\"\n",[20,1136,1137,1142,1146,1151,1156,1161],{"__ignoreMap":48},[52,1138,1139],{"class":54,"line":55},[52,1140,1141],{},"REGISTRY_HOST=\"registry.example.com\"\n",[52,1143,1144],{"class":54,"line":61},[52,1145,83],{"emptyLinePlaceholder":82},[52,1147,1148],{"class":54,"line":67},[52,1149,1150],{},"sudo mkdir -p \"\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\"\n",[52,1152,1153],{"class":54,"line":73},[52,1154,1155],{},"sudo cp pki\u002Fmtls\u002Fca\u002Fca.crt \"\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\u002Fca.crt\"\n",[52,1157,1158],{"class":54,"line":79},[52,1159,1160],{},"sudo cp pki\u002Fmtls\u002Fclients\u002Fdesktop\u002Fclient.cert \"\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\u002Fclient.cert\"\n",[52,1162,1163],{"class":54,"line":86},[52,1164,1165],{},"sudo cp pki\u002Fmtls\u002Fclients\u002Fdesktop\u002Fclient.key \"\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\u002Fclient.key\"\n",[16,1167,1168,1169,1172,1173,1176,1177,1180,1181,23],{},"Docker reads these automatically whenever it connects to that registry. No\ndaemon restart is required, and no configuration file changes are needed. The\nfile naming matters: the CA certificate must be ",[20,1170,1171],{},"ca.crt",", the client certificate\nmust end in ",[20,1174,1175],{},".cert"," (not ",[20,1178,1179],{},".crt","), and the key must end in ",[20,1182,1183],{},".key",[139,1185,1187],{"id":1186},"docker-desktop-on-linux","Docker Desktop on Linux",[16,1189,1190,1191,1194,1195,1197],{},"If you are running Docker Desktop on Linux rather than the native Docker Engine,\nthere is an additional step. Docker Desktop runs its daemon inside a ",[20,1192,1193],{},"LinuxKit","\nvirtual machine, which has its own isolated filesystem. The host's\n",[20,1196,1130],{}," is not visible inside the VM, so the daemon never sees\nyour client certificates.",[16,1199,1200,1201,1204,1205,1208,1209,1212],{},"The symptom is a ",[20,1202,1203],{},"400 Bad Request"," with the message \"No required SSL certificate\nwas sent\", even though the certificates are correctly installed on the host. You\ncan confirm this by testing with ",[20,1206,1207],{},"curl"," directly (which uses the host filesystem\nand works fine) while ",[20,1210,1211],{},"docker login"," fails.",[16,1214,1215,1216,199],{},"The fix is to copy the certificates into the VM's filesystem using ",[20,1217,1218],{},"nsenter",[43,1220,1222],{"className":706,"code":1221,"language":708,"meta":48,"style":48},"REGISTRY_HOST=\"registry.example.com\"\nHOST_CERT_DIR=\"\u002Fhost_mnt\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\"\nVM_CERT_DIR=\"\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\"\n\ndocker run --rm --privileged --pid=host alpine \\\n    nsenter -t 1 -m -- sh -c \\\n    \"mkdir -p $VM_CERT_DIR && cp $HOST_CERT_DIR\u002F* $VM_CERT_DIR\u002F\"\n",[20,1223,1224,1228,1233,1238,1242,1247,1252],{"__ignoreMap":48},[52,1225,1226],{"class":54,"line":55},[52,1227,1141],{},[52,1229,1230],{"class":54,"line":61},[52,1231,1232],{},"HOST_CERT_DIR=\"\u002Fhost_mnt\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\"\n",[52,1234,1235],{"class":54,"line":67},[52,1236,1237],{},"VM_CERT_DIR=\"\u002Fetc\u002Fdocker\u002Fcerts.d\u002F$REGISTRY_HOST\"\n",[52,1239,1240],{"class":54,"line":73},[52,1241,83],{"emptyLinePlaceholder":82},[52,1243,1244],{"class":54,"line":79},[52,1245,1246],{},"docker run --rm --privileged --pid=host alpine \\\n",[52,1248,1249],{"class":54,"line":86},[52,1250,1251],{},"    nsenter -t 1 -m -- sh -c \\\n",[52,1253,1254],{"class":54,"line":92},[52,1255,1256],{},"    \"mkdir -p $VM_CERT_DIR && cp $HOST_CERT_DIR\u002F* $VM_CERT_DIR\u002F\"\n",[16,1258,1259,1260,1262,1263,1266,1267,1269],{},"This runs a privileged Alpine container that enters the VM's mount namespace via\n",[20,1261,1218],{}," and copies the certs from the host mount (accessible at ",[20,1264,1265],{},"\u002Fhost_mnt\u002F","\ninside the VM) into the VM's own ",[20,1268,1130],{},". The certs are now\nvisible to the Docker daemon running inside the VM.",[16,1271,1272],{},"The caveat is that the VM's filesystem is ephemeral. These certificates will be\nlost whenever Docker Desktop restarts. You will need to re-run the command after\neach restart. Wrapping it in a script is the pragmatic solution.",[32,1274,1276],{"id":1275},"testing-the-setup","Testing the Setup",[139,1278,1280],{"id":1279},"starting-the-services","Starting the Services",[43,1282,1284],{"className":706,"code":1283,"language":708,"meta":48,"style":48},"docker compose up -d\n",[20,1285,1286],{"__ignoreMap":48},[52,1287,1288],{"class":54,"line":55},[52,1289,1283],{},[139,1291,1293],{"id":1292},"verifying-mtls-with-curl","Verifying mTLS with curl",[16,1295,1296,1297,1299],{},"Before involving Docker, verify that the mTLS handshake works using ",[20,1298,1207],{},". This\nisolates the TLS layer from Docker's credential handling.",[43,1301,1303],{"className":706,"code":1302,"language":708,"meta":48,"style":48},"# Without a client cert (should get 400)\ncurl -s https:\u002F\u002Fregistry.example.com\u002Fv2\u002F\n\n# With a client cert (should get 401, meaning mTLS passed)\nCERT_DIR=\"\u002Fetc\u002Fdocker\u002Fcerts.d\u002Fregistry.example.com\"\ncurl -s \\\n    --cert $CERT_DIR\u002Fclient.cert \\\n    --key $CERT_DIR\u002Fclient.key \\\n    --cacert $CERT_DIR\u002Fca.crt \\\n    https:\u002F\u002Fregistry.example.com\u002Fv2\u002F\n",[20,1304,1305,1310,1315,1319,1324,1329,1334,1339,1344,1349],{"__ignoreMap":48},[52,1306,1307],{"class":54,"line":55},[52,1308,1309],{},"# Without a client cert (should get 400)\n",[52,1311,1312],{"class":54,"line":61},[52,1313,1314],{},"curl -s https:\u002F\u002Fregistry.example.com\u002Fv2\u002F\n",[52,1316,1317],{"class":54,"line":67},[52,1318,83],{"emptyLinePlaceholder":82},[52,1320,1321],{"class":54,"line":73},[52,1322,1323],{},"# With a client cert (should get 401, meaning mTLS passed)\n",[52,1325,1326],{"class":54,"line":79},[52,1327,1328],{},"CERT_DIR=\"\u002Fetc\u002Fdocker\u002Fcerts.d\u002Fregistry.example.com\"\n",[52,1330,1331],{"class":54,"line":86},[52,1332,1333],{},"curl -s \\\n",[52,1335,1336],{"class":54,"line":92},[52,1337,1338],{},"    --cert $CERT_DIR\u002Fclient.cert \\\n",[52,1340,1341],{"class":54,"line":98},[52,1342,1343],{},"    --key $CERT_DIR\u002Fclient.key \\\n",[52,1345,1346],{"class":54,"line":104},[52,1347,1348],{},"    --cacert $CERT_DIR\u002Fca.crt \\\n",[52,1350,1351],{"class":54,"line":110},[52,1352,1353],{},"    https:\u002F\u002Fregistry.example.com\u002Fv2\u002F\n",[16,1355,1356,1357,1360,1361,1364,1365,1367],{},"A ",[20,1358,1359],{},"400"," response means NGINX rejected the connection because no client\ncertificate was presented. A ",[20,1362,1363],{},"401"," response means mTLS succeeded, and the\nregistry is asking for ",[20,1366,29],{}," credentials. Both are correct behaviour at\ntheir respective stages.",[139,1369,1371],{"id":1370},"logging-in","Logging In",[43,1373,1375],{"className":706,"code":1374,"language":708,"meta":48,"style":48},"docker login registry.example.com\n",[20,1376,1377],{"__ignoreMap":48},[52,1378,1379],{"class":54,"line":55},[52,1380,1374],{},[16,1382,1383,1384,1386,1387,1390],{},"Docker will prompt for your username and password (the values from your ",[20,1385,490],{},"\nfile). A successful login stores the credentials in ",[20,1388,1389],{},"~\u002F.docker\u002Fconfig.json",", so\nyou do not need to re-enter them for subsequent push and pull operations.",[139,1392,1394],{"id":1393},"building-and-pushing-a-test-image","Building and Pushing a Test Image",[16,1396,1397],{},"Create a minimal Dockerfile to test the full round trip.",[43,1399,1403],{"className":1400,"code":1401,"language":1402,"meta":48,"style":48},"language-dockerfile shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","# Dockerfile.test\nFROM alpine:latest\nRUN echo \"registry test image\" > \u002Fhello.txt\nCMD [\"cat\", \"\u002Fhello.txt\"]\n","dockerfile",[20,1404,1405,1410,1415,1420],{"__ignoreMap":48},[52,1406,1407],{"class":54,"line":55},[52,1408,1409],{},"# Dockerfile.test\n",[52,1411,1412],{"class":54,"line":61},[52,1413,1414],{},"FROM alpine:latest\n",[52,1416,1417],{"class":54,"line":67},[52,1418,1419],{},"RUN echo \"registry test image\" > \u002Fhello.txt\n",[52,1421,1422],{"class":54,"line":73},[52,1423,1424],{},"CMD [\"cat\", \"\u002Fhello.txt\"]\n",[16,1426,1427],{},"Build it, tag it for your registry, and push it.",[43,1429,1431],{"className":706,"code":1430,"language":708,"meta":48,"style":48},"docker build -f Dockerfile.test -t registry.example.com\u002Ftest\u002Fhello:v1 .\ndocker push registry.example.com\u002Ftest\u002Fhello:v1\n",[20,1432,1433,1438],{"__ignoreMap":48},[52,1434,1435],{"class":54,"line":55},[52,1436,1437],{},"docker build -f Dockerfile.test -t registry.example.com\u002Ftest\u002Fhello:v1 .\n",[52,1439,1440],{"class":54,"line":61},[52,1441,1442],{},"docker push registry.example.com\u002Ftest\u002Fhello:v1\n",[139,1444,1446],{"id":1445},"verifying-the-push","Verifying the Push",[16,1448,1449],{},"Query the registry API to confirm the image landed. To avoid repeating the mTLS\nflags on every curl invocation, set up a few variables first.",[43,1451,1453],{"className":706,"code":1452,"language":708,"meta":48,"style":48},"CERT_DIR=\"\u002Fetc\u002Fdocker\u002Fcerts.d\u002Fregistry.example.com\"\nCURL_MTLS=\"--cert $CERT_DIR\u002Fclient.cert --key $CERT_DIR\u002Fclient.key --cacert $CERT_DIR\u002Fca.crt\"\n\n# List all repositories\ncurl -s -u deployer:your-password $CURL_MTLS \\\n    https:\u002F\u002Fregistry.example.com\u002Fv2\u002F_catalog\n\n# List tags for the test image\ncurl -s -u deployer:your-password $CURL_MTLS \\\n    https:\u002F\u002Fregistry.example.com\u002Fv2\u002Ftest\u002Fhello\u002Ftags\u002Flist\n",[20,1454,1455,1459,1464,1468,1473,1478,1483,1487,1492,1496],{"__ignoreMap":48},[52,1456,1457],{"class":54,"line":55},[52,1458,1328],{},[52,1460,1461],{"class":54,"line":61},[52,1462,1463],{},"CURL_MTLS=\"--cert $CERT_DIR\u002Fclient.cert --key $CERT_DIR\u002Fclient.key --cacert $CERT_DIR\u002Fca.crt\"\n",[52,1465,1466],{"class":54,"line":67},[52,1467,83],{"emptyLinePlaceholder":82},[52,1469,1470],{"class":54,"line":73},[52,1471,1472],{},"# List all repositories\n",[52,1474,1475],{"class":54,"line":79},[52,1476,1477],{},"curl -s -u deployer:your-password $CURL_MTLS \\\n",[52,1479,1480],{"class":54,"line":86},[52,1481,1482],{},"    https:\u002F\u002Fregistry.example.com\u002Fv2\u002F_catalog\n",[52,1484,1485],{"class":54,"line":92},[52,1486,83],{"emptyLinePlaceholder":82},[52,1488,1489],{"class":54,"line":98},[52,1490,1491],{},"# List tags for the test image\n",[52,1493,1494],{"class":54,"line":104},[52,1495,1477],{},[52,1497,1498],{"class":54,"line":110},[52,1499,1500],{},"    https:\u002F\u002Fregistry.example.com\u002Fv2\u002Ftest\u002Fhello\u002Ftags\u002Flist\n",[139,1502,1504],{"id":1503},"pulling-the-image-back","Pulling the Image Back",[16,1506,1507],{},"Delete the local copy and pull it from the registry to verify the full cycle.",[43,1509,1511],{"className":706,"code":1510,"language":708,"meta":48,"style":48},"docker rmi registry.example.com\u002Ftest\u002Fhello:v1\ndocker pull registry.example.com\u002Ftest\u002Fhello:v1\ndocker run --rm registry.example.com\u002Ftest\u002Fhello:v1\n",[20,1512,1513,1518,1523],{"__ignoreMap":48},[52,1514,1515],{"class":54,"line":55},[52,1516,1517],{},"docker rmi registry.example.com\u002Ftest\u002Fhello:v1\n",[52,1519,1520],{"class":54,"line":61},[52,1521,1522],{},"docker pull registry.example.com\u002Ftest\u002Fhello:v1\n",[52,1524,1525],{"class":54,"line":67},[52,1526,1527],{},"docker run --rm registry.example.com\u002Ftest\u002Fhello:v1\n",[16,1529,1530,1531,23],{},"The output should print ",[20,1532,1533],{},"registry test image",[32,1535,1537],{"id":1536},"security-considerations","Security Considerations",[16,1539,1540],{},"The two authentication layers serve different purposes and protect against\ndifferent threats. mTLS operates at the transport layer and prevents\nunauthorized machines from establishing a connection at all. Without a valid\nclient certificate, NGINX terminates the TLS handshake before any HTTP request\nis processed. This is your perimeter defence.",[16,1542,896,1543,1545],{},[20,1544,29],{}," layer operates at the application level and controls who can\nperform specific actions (push, pull, catalogue listing) once a connection is\nestablished. A stolen client certificate alone is not sufficient to access\nimages because the attacker still needs valid credentials.",[16,1547,1548],{},"The CA private key is the critical asset in this setup. Anyone who obtains it\ncan sign new client certificates and bypass the mTLS check entirely. Store it on\na machine you control, restrict file permissions to your user, and do not commit\nit to version control. If you suspect the CA key has been compromised, generate\na new CA, re-issue all client certificates, and replace the CA cert in your\nNGINX configuration.",[16,1550,896,1551,1553,1554,199],{},[20,1552,490],{}," file containing registry credentials should also be excluded from\nversion control. Add both to your ",[20,1555,1556],{},".gitignore",[43,1558,1560],{"className":706,"code":1559,"language":708,"meta":48,"style":48},".env\npki\u002F\nregistry\u002Fdata\u002F\n",[20,1561,1562,1567,1572],{"__ignoreMap":48},[52,1563,1564],{"class":54,"line":55},[52,1565,1566],{},".env\n",[52,1568,1569],{"class":54,"line":61},[52,1570,1571],{},"pki\u002F\n",[52,1573,1574],{"class":54,"line":67},[52,1575,1576],{},"registry\u002Fdata\u002F\n",[32,1578,1580],{"id":1579},"wrapping-up","Wrapping Up",[16,1582,1583],{},"Running a private registry is more accessible than it might seem. The official\nDocker registry image handles the hard parts (content-addressable storage, the\ndistribution API, garbage collection), and NGINX provides a battle-tested\nreverse proxy for TLS termination. Adding mTLS on top turns a simple\npassword-protected registry into something that is genuinely difficult to access\nwithout physical control of an authorised machine.",[16,1585,1586],{},"The setup described here runs comfortably on a Raspberry Pi or any small VPS.\nThe registry itself is lightweight, and NGINX adds negligible overhead. The main\nresource consideration is disk space for stored image layers, which accumulates\nover time if you are pushing frequently. The registry supports garbage\ncollection for cleaning up unreferenced layers, which is worth configuring if\nstorage is constrained.",[1588,1589,1590],"style",{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}",{"title":48,"searchDepth":61,"depth":61,"links":1592},[1593,1594,1595,1601,1607,1615,1616],{"id":34,"depth":61,"text":35},{"id":122,"depth":61,"text":123},{"id":136,"depth":61,"text":137,"children":1596},[1597,1598,1599,1600],{"id":141,"depth":67,"text":142},{"id":156,"depth":67,"text":157},{"id":477,"depth":67,"text":478},{"id":752,"depth":67,"text":753},{"id":917,"depth":61,"text":918,"children":1602},[1603,1604,1605,1606],{"id":930,"depth":67,"text":931},{"id":993,"depth":67,"text":994},{"id":1123,"depth":67,"text":1124},{"id":1186,"depth":67,"text":1187},{"id":1275,"depth":61,"text":1276,"children":1608},[1609,1610,1611,1612,1613,1614],{"id":1279,"depth":67,"text":1280},{"id":1292,"depth":67,"text":1293},{"id":1370,"depth":67,"text":1371},{"id":1393,"depth":67,"text":1394},{"id":1445,"depth":67,"text":1446},{"id":1503,"depth":67,"text":1504},{"id":1536,"depth":61,"text":1537},{"id":1579,"depth":61,"text":1580},"2026\u002F05\u002F19","How to self-host a Docker container registry behind NGINX with htpasswd authentication and mutual TLS client certificates, locking access down to specific machines you control.","md","\u002Fimages\u002Fblog\u002Fdocker-whale-mtls.png",null,{},"\u002Fblog\u002Fsecurity\u002Fself-hosted-container-registry-mtls",{"title":11,"description":1618},"blog\u002Fsecurity\u002Fself-hosted-container-registry-mtls",[1627,765,1628,1629,1630],"docker","mtls","security","pki","nVGUCBHa56aVPv43YIKYMmIYF-QdcycP5UuA2odqfUg",1779227667102]