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.
- Sequential Gatekeeping (
needs: build-and-push)
The job begins with an explicit dependency declaration:
- Dependency Gatekeeper:
needs: build-and-pushensures 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/sitemapto 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.
- 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-runnervs.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-runnerorgithub-user) rather thanrootor your main login account (duckworth). - Docker Group Membership: The
github-runneraccount belongs to the Linuxdockergroup (usermod -aG docker github-runner), granting it permission to executedocker compose pullanddocker compose up -dwithout requiringsudo. - Directory Preparation & Permission Cleanliness: Runner directories (e.g.,
/srv/github-runners/portfolio-runner/) are created with folder ownership assigned explicitly togithub-runner:github-runner. Running./config.shviasudo -u github-runnerensures 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-runnerandsudo ./svc.sh start). This configuressystemdto 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 installper project.
- 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:
- Registry Authentication (docker login --password-stdin): Securely pipes the dynamic
${{ secrets.GITHUB_TOKEN }}intodocker login ghcr.io. Using--password-stdinprevents secret tokens from appearing in plain text in terminal logs or bash history. Authentication is mandatory if the package visibility in GHCR is set toPrivate. - Directory Navigation (cd /srv/docker/apps/astro_portfolio): Shifts terminal execution context to the specific directory where the production
docker-compose.ymlfile resides. - 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. - 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).
- Server Environment &
docker-compose.ymlConfiguration
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-stoppedguarantees that if the server reboots or the Caddy web process crashes, Docker restarts the container automatically. - Log Rotation Controls: Specifies
json-filelogging capped at 10MB across a maximum of 3 files to prevent container logs from filling up server disk space over time.
- 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)
- Ingress Traffic: Inbound web traffic passes through Cloudflare Tunnels safely into your home network.
- Central Reverse Proxy Routing: Traffic hits a central host Caddy container (
caddy:2.10) running on thefrontendbridge network. - Caddyfile Configuration: The central
/srv/docker/caddy/Caddyfileroutes incoming requests for your domain directly to the portfolio container: - Docker Internal DNS: Central Caddy resolves
astro_portfoliousing Docker's internal DNS engine, forwarding traffic over thefrontendbridge straight to port 80 of the Caddy Alpine container generated in Stage 2. - Zero-Downtime Reload: The central proxy is reloaded without dropping active HTTP connections using
docker exec caddy caddy reload --config /etc/caddy/Caddyfile.
-
GHCR Package Permissions & Troubleshooting
-
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 with403 Forbidden. - Resolution Workflow:
- Navigate to GitHub Profile → Packages → astro_portfolio → Package settings.
- Scroll down to Manage Actions access and click Add Repository.
- Select the code repository and change the role from
Readto Write or Admin. This authorizes the automated workflow token to overwrite and manage package layers seamlessly.