Deploy

The Deploy Job (deploy) represents Job 2—the Continuous Deployment (CD) phase of the GitHub Actions pipeline (.github/workflows/deploy.yml). While Job 1 (build-and-push) executes in GitHub's cloud environment (ubuntu-latest) to compile the site and push container images to GitHub Container Registry (GHCR), Job 2 executes directly on your physical homelab server (duckserver) to pull the updated image layers and perform zero-downtime container updates.


  1. Sequential Gatekeeping (needs: build-and-push)

The job begins with an explicit dependency declaration:

deploy:
  needs: build-and-push
  • Dependency Gatekeeper: needs: build-and-push ensures that Job 2 will never run if Job 1 fails during static compilation, Astro content validation, or container packaging.
  • Production Protection: If a missing collection causes @astrojs/sitemap to crash or a syntax error halts compilation in Job 1, Job 2 is aborted immediately. The homelab runner never attempts to pull non-existent or broken image tags.

  1. Self-Hosted Runner Infrastructure & Security Model

Unlike Job 1, Job 2 targets your local hardware using a self-hosted GitHub Actions runner:

  • Targeting Runner Labels (runs-on: [self-hosted, homelab, portfolio]): The pipeline matches custom labels assigned during runner registration. Isolating runners by project (e.g., portfolio-runner vs. mediaforce-runner) ensures that if one workload is compromised, it cannot access unrelated project directories.
  • Principle of Least Privilege (github-runner / github-user): The runner daemon executes under a dedicated, unprivileged system user (github-runner or github-user) rather than root or your main login account (duckworth).
  • Docker Group Membership: The github-runner account belongs to the Linux docker group (usermod -aG docker github-runner), granting it permission to execute docker compose pull and docker compose up -d without requiring sudo.
  • Directory Preparation & Permission Cleanliness: Runner directories (e.g., /srv/github-runners/portfolio-runner/) are created with folder ownership assigned explicitly to github-runner:github-runner. Running ./config.sh via sudo -u github-runner ensures credentials and working files (_work/) are generated with correct non-root permissions.
  • Persistent Daemon Service (systemd / svc.sh): The runner is registered as a system service using GitHub's helper script (sudo ./svc.sh install github-runner and sudo ./svc.sh start). This configures systemd to automatically boot the runner background process whenever the physical homelab server restarts.
  • Multi-Tenant Runner Efficiency: A single running runner daemon stays active continuously (Idle / Green), listening for workflow jobs across repositories matching its assigned tags without needing to re-run ./svc.sh install per project.

  1. Step-by-Step Deploy Job Execution Script

When triggered, Job 2 executes a four-step terminal script directly on the homelab server:

steps:
  - name: Pull and Deploy on Homelab
    run: |
      # 1. Authenticate against GHCR
      echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin

      # 2. Navigate to project directory
      cd /srv/docker/apps/astro_portfolio

      # 3. Pull fresh image layers
      docker compose pull

      # 4. Recreate container in detached mode
      docker compose up -d

Step Breakdown:

  1. Registry Authentication (docker login --password-stdin): Securely pipes the dynamic ${{ secrets.GITHUB_TOKEN }} into docker login ghcr.io. Using --password-stdin prevents secret tokens from appearing in plain text in terminal logs or bash history. Authentication is mandatory if the package visibility in GHCR is set to Private.
  2. Directory Navigation (cd /srv/docker/apps/astro_portfolio): Shifts terminal execution context to the specific directory where the production docker-compose.yml file resides.
  3. Fetching Assets (docker compose pull): Reads docker-compose.yml, identifies the target image tag (ghcr.io/ritikkumar27/astro_portfolio:latest), and downloads the updated static layers published by Job 1.
  4. Seamless Container Restart (docker compose up -d): Compares the running container state against the newly downloaded image layers. If changes are detected, Docker gracefully stops the old container, spins up a fresh container using the new image, and detaches (-d).

  1. Server Environment & docker-compose.yml Configuration

The server deployment relies on a lean docker-compose.yml file stored at /srv/docker/apps/astro_portfolio/docker-compose.yml:

services:
  portfolio:
    image: ghcr.io/ritikkumar27/astro_portfolio:latest
    container_name: astro_portfolio
    restart: unless-stopped
    networks:
      - frontend
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

networks:
  frontend:
    external: true
  • Zero Host-Port Exposure: The service definition intentionally omits a ports: block. Port 80 is not bound to the host network interface, preventing unauthorized direct access via the host IP address.
  • Bridge Network Isolation (frontend): The container joins an existing external Docker bridge network named frontend (external: true).
  • Container Lifecycle Management: restart: unless-stopped guarantees that if the server reboots or the Caddy web process crashes, Docker restarts the container automatically.
  • Log Rotation Controls: Specifies json-file logging capped at 10MB across a maximum of 3 files to prevent container logs from filling up server disk space over time.

  1. Homelab Network Topology & Reverse Proxy Integration

The Deploy Job places the container into a multi-tiered homelab network topology:

Internet / Cloudflare Tunnels 
Host Server (`duckserver`) ──> Central Caddy Container (`caddy:2.10`)
                         (frontend bridge network)
                                      │ (Docker DNS: astro_portfolio:80)
                        `astro_portfolio` Container (Caddy Alpine)
  1. Ingress Traffic: Inbound web traffic passes through Cloudflare Tunnels safely into your home network.
  2. Central Reverse Proxy Routing: Traffic hits a central host Caddy container (caddy:2.10) running on the frontend bridge network.
  3. Caddyfile Configuration: The central /srv/docker/caddy/Caddyfile routes incoming requests for your domain directly to the portfolio container:
  4. Docker Internal DNS: Central Caddy resolves astro_portfolio using Docker's internal DNS engine, forwarding traffic over the frontend bridge straight to port 80 of the Caddy Alpine container generated in Stage 2.
  5. Zero-Downtime Reload: The central proxy is reloaded without dropping active HTTP connections using docker exec caddy caddy reload --config /etc/caddy/Caddyfile.

  1. GHCR Package Permissions & Troubleshooting

  2. Handling 403 Forbidden Deployment Errors: If an image package was originally created via a manual push from a laptop terminal using a Personal Access Token (PAT), GitHub assigns package ownership strictly to your individual user account. Subsequent automated pipeline runs using ${{ secrets.GITHUB_TOKEN }} will fail during deployment/pulling with 403 Forbidden.

  3. Resolution Workflow:
    1. Navigate to GitHub ProfilePackagesastro_portfolioPackage settings.
    2. Scroll down to Manage Actions access and click Add Repository.
    3. Select the code repository and change the role from Read to Write or Admin. This authorizes the automated workflow token to overwrite and manage package layers seamlessly.