{"id":174,"date":"2026-06-22T12:00:00","date_gmt":"2026-06-22T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=174"},"modified":"2026-10-30T07:11:49","modified_gmt":"2026-10-30T07:11:49","slug":"part-10-homepage-and-container-registry","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=174","title":{"rendered":"Part 10 &#8211; Homepage and Container Registry"},"content":{"rendered":"<h3 class=\"wp-block-heading\">Overview<\/h3><p class=\"wp-block-paragraph\">In Part 9 we solved the DNS problem. Every device on the home network can now resolve *.rezaops.com to the right IP. In this article I cover two finishing touches that make the home lab feel complete.<\/p><ul class=\"wp-block-list\"><li>Homepage, a dashboard at home.rezaops.com linking to every service<\/li><li>Container Registry, a self-hosted Docker registry at registry.rezaops.com so every machine can push and pull images with a proper domain name<\/li><\/ul><h3 class=\"wp-block-heading\">Part 1 &#8211; Homepage<\/h3><h3 class=\"wp-block-heading\">Why a Homepage?<\/h3><p class=\"wp-block-paragraph\">When you have 10+ services running, remembering http:\/\/10.0.0.202 for Grafana and http:\/\/10.0.0.205:8080 for Jenkins is painful. A homepage solves this. One URL (home.rezaops.com) that links to everything.<\/p><h3 class=\"wp-block-heading\">How It Is Deployed<\/h3><p class=\"wp-block-paragraph\">The homepage is a static HTML file served by an nginx container. The HTML is stored in a Kubernetes ConfigMap. No Docker build required, no registry push. Just apply a ConfigMap and a Deployment.<\/p><pre class=\"wp-block-code\"><code>ConfigMap (HTML file)\n      |\n      | mounted as volume\n      v\nnginx container\n      |\n      | serves on port 80\n      v\nService (ClusterIP)\n      |\n      | routed by\n      v\nNGINX Ingress, home.rezaops.com<\/code><\/pre><h3 class=\"wp-block-heading\">Role: homepage<\/h3><h3 class=\"wp-block-heading\">tasks\/main.yml<\/h3><pre class=\"wp-block-code\"><code>- name: Create homepage namespace\n  shell: |\n    kubectl create namespace homepage \\\n      --dry-run=client -o yaml | kubectl apply -f -\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml<\/code><\/pre><pre class=\"wp-block-code\"><code>- name: Copy homepage HTML to remote\n  copy:\n    src: index.html\n    dest: \/tmp\/homepage-index.html<\/code><\/pre><p class=\"wp-block-paragraph\">This task must run before the ConfigMap task. This was the bug we hit. The original playbook tried to create the ConfigMap before copying the file, resulting in:<\/p><pre class=\"wp-block-code\"><code>error: error reading \/tmp\/homepage-index.html: no such file or directory<\/code><\/pre><p class=\"wp-block-paragraph\">copy with src: index.html looks for the file in roles\/homepage\/files\/index.html. It copies it to \/tmp\/homepage-index.html on the remote machine (jarvis).<\/p><pre class=\"wp-block-code\"><code>- name: Create ConfigMap with homepage HTML\n  shell: |\n    kubectl create configmap homepage-html \\\n      --from-file=index.html=\/tmp\/homepage-index.html \\\n      --namespace homepage \\\n      --dry-run=client -o yaml | kubectl apply -f -\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml<\/code><\/pre><p class=\"wp-block-paragraph\">kubectl create configmap &#8211;from-file reads the HTML file and stores its content in the ConfigMap under the key index.html. The resulting ConfigMap looks like this in the cluster:<\/p><pre class=\"wp-block-code\"><code>apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: homepage-html\n  namespace: homepage\ndata:\n  index.html: |\n    &lt;!DOCTYPE html&gt;\n    &lt;html lang=\"en\"&gt;\n    ...<\/code><\/pre><pre class=\"wp-block-code\"><code>- name: Deploy homepage\n  shell: |\n    cat &lt;&lt;EOF | kubectl apply -f -\n    apiVersion: apps\/v1\n    kind: Deployment\n    metadata:\n      name: homepage\n      namespace: homepage\n    spec:\n      replicas: 1\n      selector:\n        matchLabels:\n          app: homepage\n      template:\n        metadata:\n          labels:\n            app: homepage\n        spec:\n          nodeSelector:\n            workload: monitoring\n          containers:\n          - name: homepage\n            image: nginx:alpine\n            ports:\n            - containerPort: 80\n            volumeMounts:\n            - name: html\n              mountPath: \/usr\/share\/nginx\/html\n          volumes:\n          - name: html\n            configMap:\n              name: homepage-html\n    ---\n    apiVersion: v1\n    kind: Service\n    metadata:\n      name: homepage\n      namespace: homepage\n    spec:\n      selector:\n        app: homepage\n      ports:\n      - port: 80\n        targetPort: 80\n    ---\n    apiVersion: networking.k8s.io\/v1\n    kind: Ingress\n    metadata:\n      name: homepage-ingress\n      namespace: homepage\n    spec:\n      ingressClassName: nginx\n      rules:\n      - host: home.{{ base_domain }}\n        http:\n          paths:\n          - path: \/\n            pathType: Prefix\n            backend:\n              service:\n                name: homepage\n                port:\n                  number: 80\n    EOF\n  environment:\n    KUBECONFIG: \/etc\/rancher\/k3s\/k3s.yaml<\/code><\/pre><p class=\"wp-block-paragraph\">Three resources in one apply, Deployment, Service, and Ingress.<\/p><p class=\"wp-block-paragraph\">image: nginx:alpine is the official nginx image on Alpine Linux, about 7MB. No custom build needed.<\/p><p class=\"wp-block-paragraph\">volumeMounts mountPath: \/usr\/share\/nginx\/html replaces the default nginx welcome page with our homepage HTML. nginx serves files from this path by default.<\/p><p class=\"wp-block-paragraph\">The volumes section mounts the homepage-html ConfigMap as a directory. Each key in the ConfigMap becomes a file, so the index.html key becomes \/usr\/share\/nginx\/html\/index.html.<\/p><p class=\"wp-block-paragraph\">The Service is ClusterIP, not LoadBalancer. There is no need for a LoadBalancer here because NGINX Ingress handles external access. This is an important Kubernetes pattern, using ConfigMaps to inject files into containers without rebuilding images.<\/p><h3 class=\"wp-block-heading\">Updating the Homepage<\/h3><p class=\"wp-block-paragraph\">When you want to change the homepage:<\/p><pre class=\"wp-block-code\"><code># Edit the HTML on your Mac\nvim ~\/homelab-ansible\/roles\/homepage\/files\/index.html\n\n# Copy to jarvis\nscp roles\/homepage\/files\/index.html reza@10.0.0.148:\/tmp\/homepage-index.html\n\n# Update the ConfigMap\nssh reza@10.0.0.148\nkubectl create configmap homepage-html \\\n  --from-file=index.html=\/tmp\/homepage-index.html \\\n  --namespace homepage \\\n  --dry-run=client -o yaml | kubectl apply -f -\n\n# Restart the pod to pick up the new ConfigMap\nkubectl rollout restart deployment homepage -n homepage<\/code><\/pre><p class=\"wp-block-paragraph\">Or just rerun the Ansible playbook:<\/p><pre class=\"wp-block-code\"><code>ansible-playbook -i inventory\/hosts.ini playbooks\/09-homepage.yml<\/code><\/pre><h3 class=\"wp-block-heading\">Part 2 &#8211; Container Registry<\/h3><h3 class=\"wp-block-heading\">Why a Local Registry?<\/h3><p class=\"wp-block-paragraph\">Without a local registry, Jenkins builds an image and pushes to Docker Hub, then K3s pulls from Docker Hub which is slow, rate-limited, and requires internet. With a local registry, everything stays inside the home lab. Faster builds and no Docker Hub pull rate limits (100 pulls per 6 hours on free accounts).<\/p><h3 class=\"wp-block-heading\">Registry-Specific Ingress Annotations<\/h3><pre class=\"wp-block-code\"><code>apiVersion: networking.k8s.io\/v1\nkind: Ingress\nmetadata:\n  name: registry-ingress\n  namespace: registry\n  annotations:\n    nginx.ingress.kubernetes.io\/proxy-body-size: \"0\"\n    nginx.ingress.kubernetes.io\/proxy-read-timeout: \"600\"\n    nginx.ingress.kubernetes.io\/proxy-send-timeout: \"600\"\nspec:\n  ingressClassName: nginx\n  rules:\n  - host: registry.{{ base_domain }}\n    http:\n      paths:\n      - path: \/\n        pathType: Prefix\n        backend:\n          service:\n            name: registry\n            port:\n              number: 5000<\/code><\/pre><p class=\"wp-block-paragraph\">proxy-body-size: &#8220;0&#8221; sets the maximum allowed request body size to unlimited. By default NGINX limits request bodies to 1MB. Docker image layers can be hundreds of megabytes. Without this annotation, large image pushes fail with 413 Request Entity Too Large.<\/p><p class=\"wp-block-paragraph\">proxy-read-timeout and proxy-send-timeout set to 600 seconds (10 minutes). Pushing large Docker images over WiFi takes time. Default NGINX timeout is 60 seconds, enough for small images but not large ones.<\/p><h3 class=\"wp-block-heading\">Configuring Docker Daemon Trust<\/h3><p class=\"wp-block-paragraph\">Docker and containerd by default only accept images from HTTPS registries. Since our registry uses HTTP, we must explicitly tell them to trust it.<\/p><pre class=\"wp-block-code\"><code>- name: Configure Docker daemon to trust local registry\n  copy:\n    dest: \/etc\/docker\/daemon.json\n    content: |\n      {\n        \"insecure-registries\": [\n          \"registry.{{ base_domain }}:80\",\n          \"registry.{{ base_domain }}\",\n          \"{{ registry_ip }}:{{ registry_port }}\"\n        ]\n      }<\/code><\/pre><pre class=\"wp-block-code\"><code>- name: Restart Docker\n  systemd:\n    name: docker\n    state: restarted<\/code><\/pre><p class=\"wp-block-paragraph\">Three entries cover all the ways the registry might be addressed, via domain with explicit port, via domain without port, and via IP and port directly.<\/p><h3 class=\"wp-block-heading\">Configuring K3s containerd Trust<\/h3><p class=\"wp-block-paragraph\">K3s uses containerd (not Docker) to pull images for pods. Separate config needed:<\/p><pre class=\"wp-block-code\"><code>- name: Create K3s config directory\n  file:\n    path: \/etc\/rancher\/k3s\n    state: directory\n    mode: '0755'<\/code><\/pre><pre class=\"wp-block-code\"><code>- name: Create K3s registries config\n  copy:\n    dest: \/etc\/rancher\/k3s\/registries.yaml\n    content: |\n      mirrors:\n        \"registry.{{ base_domain }}\":\n          endpoint:\n            - \"http:\/\/registry.{{ base_domain }}\"\n        \"{{ registry_ip }}:{{ registry_port }}\":\n          endpoint:\n            - \"http:\/\/{{ registry_ip }}:{{ registry_port }}\"<\/code><\/pre><p class=\"wp-block-paragraph\">Why create the directory first? On worker nodes (thor), \/etc\/rancher\/k3s\/ does not exist until K3s agent creates it. Trying to write registries.yaml before the directory exists causes:<\/p><pre class=\"wp-block-code\"><code>Destination directory \/etc\/rancher\/k3s does not exist<\/code><\/pre><p class=\"wp-block-paragraph\">Creating it with Ansible before writing the file solves this.<\/p><h3 class=\"wp-block-heading\">Docker Desktop Configuration (MacBook)<\/h3><p class=\"wp-block-paragraph\">Docker Desktop on Mac runs in its own VM and does not inherit your Mac&#8217;s Docker daemon settings. You must configure it separately.<\/p><p class=\"wp-block-paragraph\">Docker Desktop, Settings, Docker Engine, edit JSON:<\/p><pre class=\"wp-block-code\"><code>{\n  \"insecure-registries\": [\n    \"registry.rezaops.com\",\n    \"10.0.0.206:5000\"\n  ],\n  \"dns\": [\"10.0.0.209\", \"1.1.1.1\"]\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Click Apply and Restart.<\/p><h3 class=\"wp-block-heading\">Testing the Registry<\/h3><pre class=\"wp-block-code\"><code># Pull a public image and push to your registry\ndocker pull nginx:alpine\ndocker tag nginx:alpine registry.rezaops.com\/nginx:alpine\ndocker push registry.rezaops.com\/nginx:alpine\n\n# List all images\ncurl http:\/\/registry.rezaops.com\/v2\/_catalog\n# {\"repositories\":[\"nginx\"]}\n\n# List tags for an image\ncurl http:\/\/registry.rezaops.com\/v2\/nginx\/tags\/list\n# {\"name\":\"nginx\",\"tags\":[\"alpine\"]}\n\n# Use in a Kubernetes deployment\nkubectl run test-nginx \\\n  --image=registry.rezaops.com\/nginx:alpine \\\n  --restart=Never\nkubectl get pod test-nginx<\/code><\/pre><h3 class=\"wp-block-heading\">Registry API Reference<\/h3><pre class=\"wp-block-code\"><code># List all images\ncurl http:\/\/registry.rezaops.com\/v2\/_catalog\n\n# List tags for an image\ncurl http:\/\/registry.rezaops.com\/v2\/myapp\/tags\/list\n\n# Get digest for deletion\nDIGEST=$(curl -I \\\n  -H \"Accept: application\/vnd.docker.distribution.manifest.v2+json\" \\\n  http:\/\/registry.rezaops.com\/v2\/myapp\/manifests\/oldtag \\\n  | grep Docker-Content-Digest | awk '{print $2}' | tr -d '\\r')\n\n# Delete an image tag\ncurl -X DELETE http:\/\/registry.rezaops.com\/v2\/myapp\/manifests\/$DIGEST<\/code><\/pre><h3 class=\"wp-block-heading\">What&#8217;s Next<\/h3><p class=\"wp-block-paragraph\">In Part 11 I will walk through the complete end-to-end pipeline, from writing a line of code on thor, watching Jenkins build and push the image, ArgoCD detecting the change, and the new pod starting on the cluster. Everything we built across Parts 1 to 10 working together.<\/p>","protected":false},"excerpt":{"rendered":"<p>Overview In Part 9 we solved the DNS problem. Every device on the home network can now resolve *.rezaops.com to the right&hellip;<\/p>\n","protected":false},"author":2,"featured_media":176,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"10","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/part-10-homepage-container-registry-reza-chegini-3rgkc\/","footnotes":""},"categories":[10,62],"tags":[103,8,35,66,108],"series":[64],"class_list":["post-174","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-home-lab","tag-container-registry","tag-devops","tag-docker","tag-home-lab","tag-homepage","series-home-lab"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/part-10-homepage-container-registry-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/174","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=174"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/174\/revisions"}],"predecessor-version":[{"id":175,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/174\/revisions\/175"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/176"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=174"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=174"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=174"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=174"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}