Files
2026-07-20 09:23:17 -04:00

142 KiB
Raw Permalink Blame History

tags
tags
Documentation
Bookstack
Notes

Gitea - CI / CD

Automatic Deployment Rollback

If your docker compose up -d command encounters broken network routing, bad volume mappings, or unparseable image tags, the deployment step will fail. By utilizing standard shell behaviors and Gitea's if: failure() conditions, you can instruct your runner to automatically execute a fallback deployment command using the previous stable version.

  1. Setup the Backup Directory

To safely fall back to a working state, the runner needs access to the previous commit's configuration. The cleanest way to do this without messing up your live directories is to copy your files into a temporary backup workspace on the live machine before running the fresh upgrade.

  1. The Complete Automated Rollback Workflow File

Here is the deployment and rollback code pattern. Replace your existing deployment job block with this comprehensive configuration:

deploy-with-rollback:
runs-on: ubuntu-latest
needs: build-and-push # Ensures we only deploy if the container image successfully builds

steps:
  - name: Checkout Repository Code
    uses: actions/checkout@v4

  - name: Set up SSH Routing
    run: |
      mkdir -p ~/.ssh
      echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
      chmod 600 ~/.ssh/id_ed25519
      ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts

  - name: Create Remote Docker Context
    run: |
      docker context create homelab-target \
        --docker "host=ssh://${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}"
      docker context use homelab-target

  # --- STEP A: BACKUP PREVIOUS LIVE ENVIRONMENT STATE ---
  - name: Stash Current Working Configuration
    run: |
      # Use SSH to quickly copy the current running directory configuration to a backup spot
      ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
        "mkdir -p /tmp/homelab_backup && cp -r ~/homelab_stack/* /tmp/homelab_backup/ || true"

  # --- STEP B: FRESH ATTEMPTED DEPLOYMENT ---
  - name: Sync New Config Files and Deploy
    id: deploy_attempt
    run: |
      # Copy your freshly fetched git workspace configs to your server deployment path
      scp -r ./* ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:~/homelab_stack/
      
      # Attempt the live container stack upgrade
      docker compose --project-directory ~/homelab_stack up -d --remove-orphans

  # --- STEP C: AUTOMATIC FALLBACK ROLLBACK (Triggers only if Step B crashes) ---
  - name: Execute Emergency Rollback
    if: failure() && steps.deploy_attempt.outcome == 'failure'
    run: |
      echo "⚠️ Deployment crashed! Restoring last known good stack..."
      
      # Restore the stashed backup file configuration over the broken files
      ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
        "cp -r /tmp/homelab_backup/* ~/homelab_stack/ && rm -rf /tmp/homelab_backup"
        
      # Rerun Docker Compose using the restored working configurations
      docker compose --project-directory ~/homelab_stack up -d --remove-orphans
      echo "✅ Rollback completed successfully."

🧠 Advanced Breakdown for Homelabs

  • steps.deploy_attempt.outcome == 'failure': This condition specifically targets the core deployment run. If your build or your SSH setups break, the rollback logic ignores it. It only swings into action if the docker compose up execution itself returns an error code on your target server.
  • Why Separate Config & Images?: Because you are using the Gitea Container Registry, images are tagged with the specific commit hash (:${{ gitea.sha }}). If you need to roll back, moving back to the old docker-compose.yml config will point Docker back to the previous commit's image tag. This safely pulls and starts the old container version even if you pushed a broken image to latest.

Automatically Deploy to a Live Server

🚀 Part 2: Automatically Deploy to a Live Server

The safest, cleanest way to deploy to a remote homelab server from a runner is using SSH and Docker Contexts. This allows your Gitea Actions runner to securely command your live server's Docker daemon across your local network without needing Git installed on the destination server.

  1. Generate a Deployment SSH Key [1]

On your local machine or server, generate a dedicated SSH key pair for Gitea Actions. Do not use a passphrase.

ssh-keygen -t ed25519 -f ~/.ssh/gitea_deploy_key -N ""

  • Append the contents of gitea_deploy_key.pub to the ~/.ssh/authorized_keys file on your live deployment server.
  • Copy the contents of the private key gitea_deploy_key. Save it in your Gitea Repository Secrets as DEPLOY_SSH_KEY.
  • Save your live server's local IP address or local hostname in Gitea Secrets as DEPLOY_HOST.
  • Save your live server's SSH username in Gitea Secrets as DEPLOY_USER.
  1. Create the Complete Production Workflow

Create or update your .gitea/workflows/deploy.yaml file. This workflow will validate your code first, and then deploy it only if the branch is main.

name: Validate and Deploy Homelab

on:
push:
branches: [ "main" ]

jobs:
test-and-deploy:
runs-on: ubuntu-latest

steps:
  - name: Checkout Repository Code
    uses: actions/checkout@v4

  - name: Verify Compose File Validity
    env:
      DB_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
      DB_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
    run: docker compose config

  # --- DEPLOYMENT PHASE ---
  
  - name: Set up SSH Private Key
    run: |
      mkdir -p ~/.ssh
      echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
      chmod 600 ~/.ssh/id_ed25519
      # Scan the host key to prevent SSH hanging on a manual confirmation prompt
      ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts

  - name: Create Remote Docker Context
    run: |
      # Define a remote endpoint pointing to your live server over SSH
      docker context create homelab-target \
        --docker "host=ssh://${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}"

  - name: Deploy Containers to Live Server
    env:
      # Pass your production secrets to the live deployment step
      DB_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
      DB_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
    run: |
      echo "Switching Docker context to live server..."
      docker context use homelab-target
      
      echo "Pulling latest images and starting services remotely..."
      # The --project-directory . flag ensures it reads the docker-compose file fetched by Git
      docker compose --project-directory . up -d --remove-orphans

💡 Why This Setup Wins for Homelabs

  • Zero Overhead: You do not need to install complex deployment tools or agents on your production server. It only requires standard SSH and Docker.
  • Security: If you ever need to revoke the runner's deployment access, you simply delete the single public key from your live server's authorized_keys file.
  • Atomic Changes: The --remove-orphans flag cleans up old containers that you removed from your Compose file since the last Git commit, keeping your server immaculate.

Build & Push to Gitea Container Registry First

Building images and storing them inside Gitea's built-in registry (://example.com) ensures that you can decouple your actual build step from the live deployment server. It also provides a snapshot history of your homelab services.

  1. Update your docker-compose.yml to point to Gitea [1]

Change your local container configuration so that instead of building in place, it pulls a tagged image hosted right inside your Gitea instance:

services:
  custom-app:
    # Point the image path directly to your self-hosted Gitea registry URL
    image: gitea.homelab.local/${{ gitea.repository_owner }}/custom-app:latest
    ports:
      - "8080:80"

  1. Create the Build & Push Job Step

Add this block right before your deployment phase. Gitea Actions automatically exposes a temporary validation token (${{ gitea.token }}) for every run, which means you don't need to manually configure registry usernames or passwords in your secrets dashboard! [1]

build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4

  # Log directly into your self-hosted Gitea Container Registry
  - name: Log in to Gitea Registry
    uses: https://github.com
    with:
      registry: gitea.homelab.local # Replace with your real homelab Gitea domain or local IP
      username: ${{ gitea.actor }}
      password: ${{ gitea.token }}

  # Extract metadata to properly tag your image with the Git commit hash and "latest"
  - name: Extract Docker Metadata
    id: meta
    uses: https://github.com
    with:
      images: gitea.homelab.local/${{ gitea.repository }}
      tags: |
        type=raw,value=latest
        type=sha,value=${{ gitea.sha }}

  # Build the Dockerfile and push it natively to Gitea
  - name: Build and Push Docker Image
    uses: https://github.com
    with:
      context: ./app_directory # Location of your Dockerfile
      push: true
      tags: ${{ steps.meta.outputs.tags }}

Handle Local Storage Path Differences

In a homelab, your production server usually has unique storage mounts (like /mnt/user/appdata/nginx or /mnt/SSD/data), but your Gitea runner is isolated and lacks access to those specific paths. If you try to run a verification command like docker compose config inside the runner, it will often fail or generate unwanted folders.

Here are the two cleanest ways to solve this:

Solution 1: Use Environment Variables for Paths (Recommended)

Instead of hardcoding absolute paths into your docker-compose.yml, replace them with environment variables.

Update your docker-compose.yml to use a variable:
services:
nginx:
image: nginx:alpine
volumes:
# Use a variable with a safe local fallback directory for testing
- ${DATA_PATH_NGINX:-./nginx_test_data}:/etc/nginx

Map the variables differently in your workflow steps:
When validating on the runner, let it fall back to the safe relative directory. When deploying to production, explicitly inject your real server paths.

  # Step A: Validate (Uses the relative path fallback automatically)
  - name: Verify Compose File Validity
    run: docker compose config

  # Step B: Deploy (Injects your actual live server paths)
  - name: Deploy Containers to Live Server
    env:
      DATA_PATH_NGINX: "/mnt/user/appdata/nginx" # Real production path
    run: |
      docker context use homelab-target
      docker compose --project-directory . up -d --remove-orphans

Solution 2: Store Production Paths in Gitea Secrets

If you want to keep your specific homelab storage architecture hidden from your Git repository code entirely, you can save the base storage directory directly as a Gitea Secret (e.g., BASE_DATA_PATH = /mnt/user/appdata).

  1. Reference the secret variable in your docker-compose.yml:

    services:
      plex:
        image: plexinc/pms
        volumes:
          - ${BASE_DATA_PATH}/plex:/config
    
    

Provide a dummy path during validation, and the real secret during deployment:

  - name: Verify Compose File Validity
    env:
      BASE_DATA_PATH: "./test_env" # Dummy folder for the runner
    run: docker compose config

  - name: Deploy Containers to Live Server
    env:
      BASE_DATA_PATH: ${{ secrets.BASE_DATA_PATH }} # Real server path
    run: |
      docker context use homelab-target
      docker compose --project-directory . up -d --remove-orphans

💡 Putting It Together

By combining these two methods, your workflow will safely parse your configuration using fake/relative folders on the runner service, seamlessly deploy using your live production hard drive arrays over SSH, and instantly ping your phone via Discord or Telegram with the results.

Inject Gitea Secrets

🔒 Part 1: Inject Gitea Secrets into Compose Files Safely

Hardcoding sensitive data like passwords, API keys, or database credentials into your Git repository is a major security risk. Instead, you should store them in Gitea and pass them dynamically at runtime. [1, 2, 3, 4]

  1. Add Secrets to Gitea
  2. Go to your repository in Gitea.
  3. Navigate to Settings ➡️ Actions ➡️ Secrets.
  4. Click 貯/Add Secret.
  5. Add your variables (e.g., Key: MYSQL_PASSWORD, Value: super_secret_password_123). [1, 2, 3]
  6. Update Your docker-compose.yml

Configure your Compose file to look for standard environment variables. Do not include default values here.

services:
  database:
    image: mysql:8.0
    environment:
      - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
      - MYSQL_PASSWORD=${DB_PASSWORD}

  1. Update Your Gitea Workflow File

You can map Gitea Secrets into an environment block right inside your workflow step. When docker compose runs, it will read those variables from the runner's system environment and inject them into the container definition. [1]

  - name: Verify Compose File Validity
    env:
      # Map Gitea Secrets to the environment variables expected by your compose file
      DB_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
      DB_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
    run: |
      echo "Injecting secrets and testing configuration..."
      docker compose config

Send a Discord or Telegram Notification

Here is how to add instant notifications to your setup and solve the common homelab issue of mismatched storage paths between your testing runner and your live production server.


🔔 Part 1: Send a Discord or Telegram Notification

You can easily append a notification step to the very end of your workflow. By utilizing Gitea's conditional blocks (if: always()), you can ensure you get notified whether the deployment succeeds or crashes.

Option A: Sending to Discord

  1. Open your Discord server, go to your target channel settings, click Integrations, and create a Webhook. Copy the URL.

  2. Store that URL in your Gitea Repository Secrets as DISCORD_WEBHOOK.

      - name: Send Discord Status Notification
        if: always() # Ensures this runs even if the deployment failed
        uses: https://github.com
        with:
          verify: false
          url: ${{ secrets.DISCORD_WEBHOOK }}
    
      # Evaluates the final outcome of the job to change the message dynamically
      title: "Homelab Deployment: ${{ job.status == 'success' && '✅ SUCCESS' || '❌ FAILED' }}"
      description: "The commit '${{ gitea.event.head_commit.message }}' by ${{ gitea.actor }} has finished processing."
      color: ${{ job.status == 'success' && '0x00FF00' || '0xFF0000' }}
    
    

Option B: Sending to Telegram

  1. Message @BotFather on Telegram to create a new bot and copy your Bot Token.
  2. Message @userinfobot to find your personal Chat ID.
  3. Save these in Gitea Secrets as TELEGRAM_TOKEN and TELEGRAM_TO.
    • name: Send Telegram Status Notification
      if: always()
      uses: https://github.com
      with:
      token: ${{ secrets.TELEGRAM_TOKEN }}
      to: ${{ secrets.TELEGRAM_TO }}
      message: |
      Homelab Deploy Status: ${{ job.status }}
      Actor: ${{ gitea.actor }}
      Commit: ${{ gitea.event.head_commit.message }}

Test and validate your compose configurations

To test and validate your docker-compose infrastructure configurations right inside your homelab, you can use Gitea Actions to automatically check your YAML syntax and test your container builds every time you make an edit. [1, 2]

Here is how to set up an automated testing pipeline specifically for a homelab Docker Compose repository.

🛠️ Step 1: Set Up the Workflow Directory

In your homelab Git repository containing your docker-compose.yml files, create the specific workflow path: [1]

  • File path: .gitea/workflows/docker-test.yaml

📝 Step 2: Paste the Homelab Validation Template

Copy and paste this template into your new file. It is optimized to perform two critical steps: lint your YAML formatting for typos, and dry-run your Docker Compose setup to ensure your container configurations are valid. [1, 2]

name: Docker Compose Validator
run-name: Homelab configuration test by ${{ gitea.actor }} 🛠️

on:
push:
branches: [ "main", "master" ]
pull_request:
branches: [ "main", "master" ]

jobs:
validate-and-test:
runs-on: ubuntu-latest

`steps:`
  `# 1. Pull down your homelab repository code`
  `- name: Checkout Repository Code`
    `uses: actions/checkout@v4`

  `# 2. Check all YAML files for syntax, indentation, and formatting errors`
  `- name: Lint YAML Syntax`
    `uses: https://github.com`
    `with:`
      `file_or_dir: "."`

  `# 3. Verify that the docker-compose syntax is structurally sound`
  `- name: Verify Compose File Validity`
    `run: |`
      `echo "Checking docker-compose configuration..."`
      `docker compose config`

  `# 4. Dry-run the build sequence to ensure images and contexts are reachable`
  `- name: Test Build Sequence`
    `run: |`
      `echo "Simulating container build sequence..."`
      `docker compose build`

🔍 Breaking Down How This Works for Homelabbers

  • YAML Linting: Homelab configurations frequently break due to an accidental extra space or missing indentation. The action-yamllint step scans your files and alerts you to formatting errors before they hit your live system.
  • docker compose config: This command acts as a built-in validator. It reads your docker-compose.yml file, processes any variables, and outputs an error if you have typos in your keys, volume definitions, or network syntax. [1, 2, 3]
  • docker compose build: If your compose files use the build: block to compile local Dockerfiles instead of just pulling pre-made images, this step ensures your Dockerfiles successfully build without crashing.

⚠️ Crucial Homelab Gotchas to Keep in Mind

  1. Environment Variables (.env): If your docker-compose.yml relies on an external .env file containing local paths, secret tokens, or personal domain names, docker compose config will fail in Gitea Actions because that file is missing. You should either add a .env.example file to your Git repository and rename it during the workflow run, or map those variables into Gitea Secrets.
  2. Local Volume Paths: If your compose files map to absolute local storage paths (e.g., /mnt/user/appdata/nginx), the Gitea Act Runner environment won't have access to those actual directories. For testing purposes, it is safest to use relative paths (e.g., ./nginx/config) or let Docker generate named volumes during the validation phase.
  3. Runner Capabilities: Ensure that your local act_runner setup has access to a Docker daemon. If you installed your runner using Docker-in-Docker (DinD), it will easily execute the docker compose commands embedded in this workflow. [1]

Optimize Your docker-compose.yml Health Checks

Ensure your containers have proper `interval`, `timeout`, and `retries` settings. Keep the startup window tight so your Gitea runner doesn't time out waiting for the status. [[1](https://oneuptime.com/blog/post/2026-01-30-docker-health-check-best-practices/view), [2](https://cyberpanel.net/blog/docker-compose-healthcheck)]
yaml
``` services: web-app: image: gitea.homelab.local/${{ gitea.repository_owner }}/web-app:latest ports: - "8080:80" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:80/health"] interval: 10s # How often to run the test timeout: 5s # How long to wait for a response retries: 3 # Number of consecutive failures before declaring "unhealthy" start_period: 5s # Grace period for the container to boot up initially

<div class="r1PmQe" id="bkmrk-use-code-with-cautio"><div><div class="pHpOfb"><div class="pCTyYe" dir="ltr"></div></div><div class="LIBz9e"><div class="P8PNlb">Use code with caution.</div><div><div class="ypuoue">  
</div></div></div></div></div><div class="Fsg96" id="bkmrk--1"></div>---

<div class="Fsg96" id="bkmrk--3"></div><div class="otQkpb" id="bkmrk-%F0%9F%9A%80-step-2%3A-update-you">🚀 Step 2: Update Your Gitea Workflow File</div><div class="n6owBd awi2gc" id="bkmrk-we-will-update-your-">We will update your deployment step to include the `--wait` flag. We will also update your notification step so that you receive an urgent alert if a health check fails and an emergency rollback is forced.</div><div class="Fsg96" id="bkmrk--4"></div><div class="r1PmQe" id="bkmrk-yaml-1"><div><div class="pHpOfb"><div class="z0e9Qd"><div class="vVRw1d">yaml</div></div><div class="pCTyYe" dir="ltr"></div></div></div></div>```
  deploy-with-health-rollback:
    runs-on: ubuntu-latest
    needs: build-and-push
    
    steps:
      - name: Checkout Repository Code
        uses: actions/checkout@v4

      - name: Set up SSH Routing
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts

      - name: Create Remote Docker Context
        run: |
          docker context create homelab-target \
            --docker "host=ssh://${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}"
          docker context use homelab-target

      # --- STEP A: BACKUP CONFIG ---
      - name: Stash Current Working Configuration
        run: |
          ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
            "mkdir -p /tmp/homelab_backup && cp -r ~/homelab_stack/* /tmp/homelab_backup/ || true"

      # --- STEP B: DEPLOY & WAIT FOR HEALTH CHECKS ---
      - name: Sync New Config Files and Deploy
        id: deploy_attempt
        run: |
          scp -r ./* ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:~/homelab_stack/
          
          echo "Deploying and monitoring container health status..."
          # The --wait flag blocks the terminal until healthchecks return green.
          # If a health check fails or times out, this command throws an exit code 1.
          docker compose --project-directory ~/homelab_stack up -d --remove-orphans --wait

      # --- STEP C: EMERGENCY ROLLBACK (Runs if health checks fail) ---
      - name: Execute Emergency Rollback
        if: failure() && steps.deploy_attempt.outcome == 'failure'
        run: |
          echo "⚠️ Health checks failed! Rolling back to last stable version..."
          
          # Restore previous files
          ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
            "cp -r /tmp/homelab_backup/* ~/homelab_stack/ && rm -rf /tmp/homelab_backup"
            
          # Force deploy the old stable containers and wait for them to heal
          docker compose --project-directory ~/homelab_stack up -d --remove-orphans --wait
          echo "✅ Rollback completed and confirmed healthy."

      # --- STEP D: DISCORD / TELEGRAM ALERTS ---
      - name: Send Deployment Status Notification
        if: always()
        uses: https://github.com
        with:
          verify: false
          url: ${{ secrets.DISCORD_WEBHOOK }}
          title: >-
            ${{ steps.deploy_attempt.outcome == 'success' && '✅ Deployment Success' || 
               (steps.deploy_attempt.outcome == 'failure' && '⚠️ Deployment Failed - Rolled Back Successfully' || '❌ Pipeline Crash') }}
          description: "Stack status verification processed for homelab branch: ${{ gitea.ref }}"
          color: ${{ steps.deploy_attempt.outcome == 'success' && '0x00FF00' || '0xFF0000' }}

Use code with caution.
---
🧠 Why the `--wait` Flag Rules Your Homelab Pipeline
- **Catches Runtime Exceptions:** If your YAML configuration is valid, but your application throws a database connectivity error or a segmentation fault 3 seconds after boot, the health check will fail. Gitea catches it immediately. \[[1](https://yandex.cloud/en/docs/compute/concepts/instance-groups/autohealing)\] - **Prevents Downtime:** Because Docker keeps the old container instances running until the *new* ones pass their health checks (when using standard rolling updates), your users/devices experience zero downtime if a bad commit tries to deploy. \[[1](https://github.com/ansible-collections/community.docker/issues/794), [2](https://oneuptime.com/blog/post/2026-01-06-docker-update-without-downtime/view), [3](https://github.com/caprover/caprover/discussions/1287)\] - **Dynamic Notifications:** The `title:` logic inside the notification step evaluates exactly what happened. You will get a bright green message if everything works, and an explicit warning if an app failed its health check but was safely rolled back.

Setting up deploy for sub directories in a repository.

Ok, i made it down the list you provide below. I created a repository call watchtower and created a compose file and a env file inside the repository. I had to stop at the setting up webhook, as I have never done this before and got lost. So I will need help on getting that created and then follow on tasks.

Testing CI/CD

Copy this into Gemini to contine.

When you are diving into Docker, the best way to actually internalize how it works is to move quickly from running simple, single-container software to managing multi-container infrastructure, advanced networking, and self-hosted automation.

Here is a progression of excellent Docker projects to build and play around with, ranging from core fundamental concepts to complex, production-style orchestration.

---

## 1. The Gateway: Multi-Container Web App with Docker Compose

Instead of just running an isolated web server, build a classic tiered architecture. This teaches you how containers communicate inside an isolated network without relying on exposed host ports.

* **The Stack:** A lightweight frontend/backend (like Python Flask, Node.js, or Go) coupled with a persistent database (PostgreSQL, MariaDB, or Redis).

* **What youll learn:** * Writing a structural Dockerfile using slim or alpine base images.

* Defining distinct network topologies in a docker-compose.yml file so your backend can resolve the database simply by its service name (e.g., db:5432).

* Using named **Docker volumes** to ensure your database data persists when the containers are torn down and rebuilt.

## 2. The Homelab Core: Advanced Reverse Proxying & Internal Routing

Once you can launch apps, the next logical step is figuring out how to traffic route them elegantly. Setting up a central reverse proxy is one of the most rewarding milestones in understanding container networking.

* **The Stack:** Traefik, Nginx Proxy Manager, or Caddy.

* **What youll learn:**

* Routing incoming HTTP/HTTPS traffic to specific container backends based on domain headers or subdomains.

* **Docker Socket Bindings:** Binding /var/run/docker.sock to tools like Traefik or a dashboard, allowing them to dynamically discover containers as they spin up.

* Automating SSL wildcard certificates via Let's Encrypt using DNS-01 challenges.

## 3. The Security & Privacy Node: Network Namespacing & VPN Routing

To get a deep, practical understanding of how container networking interfaces function, try forcing a group of applications to strictly route their traffic through a single gateway container.

* **The Stack:** A VPN container running Wireguard (like Gluetun) paired with downstream services like network downloaders or self-hosted scraping scripts.

* **What youll learn:**

* **Network Namespacing:** Forcing downstream containers to bypass standard bridged networks and share the network stack of the VPN container (network\_mode: "service:vpn").

* Configuring custom local DNS rules and resolving localized API timeouts when services lose standard internet access if the VPN interface drops (kill-switch mechanics).

## 4. The Self-Hosted Cloud: Unified Media & Storage Infrastructure

Building a robust, inter-dependent application stack is where you learn to master system file permissions, environment variables, and centralized management.

* **The Stack:** Immich (for photo backup), Nextcloud, or a comprehensive *arr media suite tied into an interactive dashboard (like Homepage or Dashy).

* **What youll learn:**

* Managing complex directory mapping, relative volume paths, and environment variable files (.env).

* Dealing with **UID/GID mapping** to ensure rootless containers or specific user groups have proper read/write permissions on the host system filesystem without throwing initialization panics.

* Integrating widget APIs across a secure network layer to track container health, resource utilization, and operational statuses.

## 5. The Enterprise Sandbox: Local Automation & CI/CD Pipelines

If you want to treat your container ecosystem like an enterprise deployment, pull away from manual command execution and automate the entire workflow locally.

* **The Stack:** Semaphore UI or containerized Ansible runner agents, Gitea (self-hosted git repository), and Watchtower.

* **What youll learn:**

* Automating host configuration adjustments using containerized orchestration.

* Configuring webhooks to automatically rebuild or redeploy stacks when code or configuration files change.

* Using automated monitoring sidecars (like Watchtower) to parse the local Docker daemon, check upstream registries for image updates, and gracefully recreate services with zero manual input.

Setting up a localized automation sandbox is a massive milestone. It shifts you away from thinking about containers as individual pets that you manually spin up, and moves you toward treating your entire infrastructure as code (IaC).

To build a true enterprise-style local CI/CD and automation pipeline, you can structure your sandbox around three main phases: code storage, automated execution, and continuous deployment.

---

## The Automation Stack Blueprint

To get this running smoothly, you will want to deploy a small ecosystem of interconnected containers that talk to each other and your host system.

### 1. The Code Repository: Gitea or Forgejo

Instead of pushing your Docker Compose files and automation scripts to a public GitHub repo, host your own lightweight git service.

* **Why it's essential:** It serves as your local "source of truth." Every time you update a Compose template or an orchestration script, you commit it here.

* **What to practice:** Setting up repository **Webhooks**. You'll configure Gitea to automatically ping your orchestration engine the second you push a code change.

### 2. The Orchestration Engine: Semaphore UI or Ansible Runners

This acts as your control plane. Instead of SSH-ing into servers or manually typing commands into your terminal, this container runs your automation tasks for you.

* **Why it's essential:** It standardizes how tasks are executed. It pulls your latest configuration files directly from your local git repo and runs them against your target environments.

* **What to practice:** * Managing sensitive data (like SSH keys and API tokens) securely using environment variables or vault keys within the UI.

* Structuring inventory files to map out your local container hosts or LXC servers.

* Writing playbooks or scripts that can dynamically parse local directories, map required files, and deploy stacks without throwing deprecation warnings.

### 3. The Lifecycle Manager: Watchtower

Once containers are deployed, you need a hands-off way to keep them maintained without breaking things.

* **Why it's essential:** Watchtower monitors the Docker daemon. It can watch your local repository or upstream registries, pull down updated images, and gracefully recreate the container using the exact same configuration variables it was born with.

* **What to practice:** Configuring granular control. Instead of letting it update everything blindly, practice using Docker labels to tell Watchtower *exactly* which containers it's allowed to update, and which ones it should leave alone.

---

## Key Concepts to Focus On

As you stitch these tools together, you'll run into a few classic engineering hurdles that are fantastic for learning:

* **The Docker Socket vs. SSH Agents:** Deciding whether your automation container should directly manage the host's Docker daemon by binding /var/run/docker.sock, or if it should securely authenticate over SSH using keys.

* **Webhook Authentication:** Securing the communication channel between your Git repository and your runner so that only validated payloads can trigger a system deployment.

* **Directory & Requirement Scoping:** Ensuring that when your automation runner executes a task, it properly pulls down all necessary configuration sub-directories and missing requirement files before running the deployment phase.

An excellent first target for this pipeline is automating the deployment of a simple, non-critical service. Try writing a configuration file, committing it to your local git repo, and watching the webhooks and runners automatically stand the service up for you.

Do you want to start by mapping out the unified docker-compose framework to get Gitea and an orchestration runner talking to each other on a shared network layer?

Here is a complete, standardized docker-compose.yml framework to get your automation hub up and running.

This setup deploys **Gitea** (your local git repository) and **Semaphore** (your Ansible orchestration engine) onto a dedicated, isolated internal network layer. It also includes a secured **Docker Socket Proxy** (tecnativa/docker-socket-proxy). Instead of exposing your host's raw Docker daemon directly to Semaphore—which is a major security risk—this proxy acts as a firewall, explicitly allowing Semaphore to *only* use the specific Docker API calls it needs to deploy containers.

---

### The Infrastructure Compose Framework

Create a directory on your host (e.g., ~/automation-sandbox) and drop the following configuration into a docker-compose.yml file.


version: "3.8"

networks:

 automation\_net:

 name: automation\_network

 driver: bridge

volumes:

 gitea\_data:

 gitea\_db:

 semaphore\_data:

services:

 # -------------------------------------------------------------------

 # 1. THE SECURITY LAYER: Docker Socket Proxy

 # -------------------------------------------------------------------

 docker-proxy:

 image: tecnativa/docker-socket-proxy:latest

 container\_name: automation\_docker\_proxy

 privileged: true

 volumes:

\- /var/run/docker.sock:/var/run/docker.sock:ro

 environment:

\- CONTAINERS=1

\- NETWORKS=1

\- SERVICES=1

\- VOLUMES=1

\- POST=1 # Allows Semaphore to create/start components

 networks:

\- automation\_net

 restart: unless-stopped

 # -------------------------------------------------------------------

 # 2. THE REPOSITORY: Gitea

 # -------------------------------------------------------------------

 gitea:

 image: gitea/gitea:1.21

 container\_name: automation\_git

 environment:

\- USER\_UID=1000

\- USER\_GID=1000

\- GITEA\_\_database\_\_DB\_TYPE=sqlite3

 volumes:

\- gitea\_data:/data

\- /etc/timezone:/etc/timezone:ro

\- /etc/localtime:/etc/localtime:ro

 ports:

\- "3000:3000" # Web UI

\- "2222:22" # SSH container access

 networks:

\- automation\_net

 restart: unless-stopped

 # -------------------------------------------------------------------

 # 3. THE ORCHESTRATION ENGINE: Semaphore UI

 # -------------------------------------------------------------------

 semaphore:

 image: semaphoreui/semaphore:latest

 container\_name: automation\_runner

 ports:

\- "3020:3000" # Web UI accessed via port 3020

 environment:

\- SEMAPHORE\_DB\_DIALECT=bolt

\- SEMAPHORE\_ADMIN\_PASSWORD=adminpassword # Change this on first boot!

\- SEMAPHORE\_ADMIN\_NAME=Admin

\- SEMAPHORE\_ADMIN\_EMAIL=admin@local.lan

\- SEMAPHORE\_ADMIN=admin

 # Point Semaphore to our secure local Docker Proxy instead of raw socket

\- DOCKER\_HOST=tcp://docker-proxy:2375

 volumes:

\- semaphore\_data:/var/lib/semaphore

 networks:

\- automation\_net

 depends\_on:

\- docker-proxy

\- gitea

 restart: unless-stopped

---

### Step-by-Step Architecture Walkthrough

1. **The Shared Network Namespace:** Both Gitea and Semaphore are attached to automation\_network. This means they can discover and communicate with each other using their exact service names (http://gitea:3000 and http://semaphore:3000) completely bypassing host port routing internally.

2. **Decoupled Engine (bolt):** For simplicity and to prevent multi-container database dependencies right out of the gate, Semaphore is configured to use an embedded Key/Value Bolt database, and Gitea is running on an independent local SQLite instance. This keeps your state files locked cleanly inside named volumes.

3. **The Proxy Target:** By supplying DOCKER\_HOST=tcp://docker-proxy:2375 to the runner engine, any Ansible playbook or Docker command spawned inside Semaphore will automatically route through the API filter rather than needing the raw host socket mounted directly.

### Initial Configuration Tasks to Run

Fire up the stack using docker compose up -d and complete these two baseline steps to link them:

* **Initialize the Repository:** Navigate to http://&lt;your-host-ip&gt;:3000 to open Gitea. Complete the quick web configuration setup (leaving it as SQLite). Create a user account, initialize a blank repository, and push a test project containing a basic Ansible playbook or a target compose file.

* **Link the Webhook:** Inside your new Gitea repository settings, head to **Webhooks** and add a generic webhook pointing directly to your Semaphore target.

Awesome job getting the repository initialized with your Compose and .env files! Moving from manual operations to webhooks is where the magic of CI/CD actually starts.

Think of a webhook as an automated phone call. Whenever you push a code change to Gitea, Gitea immediately "calls" Semaphore and says, "Hey, I have new code. Run the deployment now."

Since you have your watchtower repository ready in Gitea, here is how to bridge the gap between Gitea and Semaphore step-by-step.

Step 1: Create the Trigger in Semaphore UI

Before Gitea can send a notification, Semaphore needs to generate a unique entry point (a webhook URL) for that specific task.

1. Open your **Semaphore UI** dashboard (`http://:3020`). 2. Navigate to your project, and go to **Task Templates** (this is the configuration that tells Semaphore *how* to run your Docker Compose commands). 3. Create or edit your task template for deploying Watchtower. 4. Look for the **Build Triggers** or **Webhooks** section within that template. 5. Enable **Webhook Triggers**. Semaphore will generate two critical pieces of information:
- **Webhook URL:** It will look something like `http://<semaphore-host>:3000/api/project/.../tasks/...`
- **Webhook Secret:** A long string of random characters used to verify that the call is genuinely coming from your Gitea server.
*Copy both of these values down.*

Step 2: Configure the Webhook in Gitea

Now we tell Gitea where to send the signal when you push code.

1. Open **Gitea** (`http://:3000`) and navigate into your `watchtower` repository. 2. Click on **Settings** in the top-right corner of the repository menu. 3. On the left sidebar, click on **Webhooks**. 4. Click the **Add Webhook** button and select **Gitea** (or generic HTTP if Gitea isn't listed explicitly, though "Gitea" is preferred). 5. Fill out the form fields using the data you got from Semaphore:
- **Target URL:** Paste the Webhook URL from Semaphore. *Note: If Gitea and Semaphore are on the same Docker network, you can use `http://automation_runner:3000/api/...` instead of the host IP.*
- **HTTP Method:** `POST`
- **Content Type:** `application/json`
- **Secret:** Paste the Webhook Secret you copied from Semaphore.
  1. Under Trigger On, select Push Events (this means it only triggers when you push code).
  2. Ensure the Active checkbox is checked, and click Add Webhook.
## Step 3: Test the Connection

Gitea has a built-in feature to make sure the two containers are speaking to each other properly.

1. Click on the webhook you just created in Gitea to view its details. 2. Scroll down to the bottom to find the **Test Delivery** button. 3. Click it. Gitea will send a mock payload to Semaphore. 4. Look at the response:
- A **Green checkmark (200 or 201 OK)** means success! Semaphore successfully received the ping.
- A **Red cross (400 or 500 error)** means something is misconfigured (usually a typo in the URL or the container cannot resolve the network address).
## Follow-on Tasks: What to do next

Once you get a successful test delivery, your pipeline is officially connected. Here is what you should do next to see it in action:

1. **Make a dummy change:** Open the `.env` file or a readme file inside your local `watchtower` repository, make a minor edit (like adding a comment line), and commit/push it to Gitea. 2. **Watch the runner:** Immediately switch over to your Semaphore UI dashboard. You should see a new task automatically spin up and start executing your configuration scripts without you hitting a single button.
Did you run into any network resolution errors when running the test delivery, or did it go through cleanly?
Ok, I am stuck on 3 (Create or edit your task template for deploying Watchtower.) Not sure what to do here need more information. Do I create a Ansible, terraform or bash template?

It is completely normal to hit a wall here—Semaphore is incredibly flexible, which means it doesn't force a single path on you, but that can easily lead to "choice paralysis."

For deploying a standard docker-compose.yml file like the one you built for Watchtower, you should create a Bash template.

While Ansible is amazing for multi-server enterprise environments, a simple Bash script is the quickest, most lightweight way to run a local docker compose up -d command inside your sandbox.

Here is exactly how to configure that Task Template in Semaphore.

Step-by-Step Task Template Setup

1. In Semaphore, go to your project dashboard and click on **Task Templates**, then click **New Template**. 2. Fill out the configuration fields with these exact settings:
FieldSettingWhat it means
**Name**`Deploy Watchtower`Just a recognizable name for your task.
**Type****Bash**Tells Semaphore to execute this using a standard shell script.
**Repository**Select your `watchtower` repoThis tells Semaphore to pull down your Watchtower code files before running.
**Environment**Select your default environmentContains any global environment variables you set up.
## The Inline Script

Scroll down to the Inline Script or Playbook/Script filename section. Instead of pointing to a file, we are going to write a tiny, two-line script directly into Semaphore to handle the deployment.

Paste this into the script box:

Bash
``` #!/bin/bash cd $SEMAPHORE_PROJECT_REPOSITORY_PATH docker compose down && docker compose up -d --remove-orphans

### What those lines actually do:

<div class="conversation-container message-actions-hover-boundary ng-star-inserted" id="bkmrk-cd-%24semaphore_projec"><div><div class="response-container response-container-with-gpi ng-tns-c402320660-176 no-background"><div class="presented-response-container ng-tns-c402320660-176"><div class="response-container-content ng-tns-c402320660-176"><div class="response-content ng-tns-c402320660-176"><div class="container"><div class="markdown markdown-main-panel tutor-markdown-rendering enable-luminous-fast-follows stronger enable-updated-hr-color" dir="ltr">1. **`cd $SEMAPHORE_PROJECT_REPOSITORY_PATH`**: When Semaphore triggers, it automatically clones your Gitea repository into a temporary folder. This environment variable dynamically drops the script right into the folder where your `docker-compose.yml` and `.env` files live.
2. **`docker compose down && docker compose up -d`**: This tears down the old container (if it's running) and stands up the brand-new one using the updated files you just pushed to Gitea.

</div></div></div></div></div></div></div></div>## Final Step: Turn on the Trigger

Once those settings are filled in:

<div class="conversation-container message-actions-hover-boundary ng-star-inserted" id="bkmrk-look-at-the-bottom-o"><div><div class="response-container response-container-with-gpi ng-tns-c402320660-176 no-background"><div class="presented-response-container ng-tns-c402320660-176"><div class="response-container-content ng-tns-c402320660-176"><div class="response-content ng-tns-c402320660-176"><div class="container"><div class="markdown markdown-main-panel tutor-markdown-rendering enable-luminous-fast-follows stronger enable-updated-hr-color" dir="ltr">1. Look at the bottom of that same creation screen for **Build Triggers** or **Webhook**.
2. Check the box to enable it.
3. Click **Save**.

</div></div></div></div></div></div></div></div>Now that the template is saved, you will see it listed on your dashboard. When you click into it, you'll find the unique **Webhook URL** and **Secret** generated for this specific Bash task. You can now take those values and plug them into Gitea as outlined in the previous step!

Give that a shot—does Semaphore accept the template without giving you any errors?

<div class="conversation-container message-actions-hover-boundary ng-star-inserted" id="bkmrk--92"><div><div class="response-container response-container-with-gpi ng-tns-c402320660-176 no-background"><div class="presented-response-container ng-tns-c402320660-176"><div class="response-container-content ng-tns-c402320660-176"><div class="response-content ng-tns-c402320660-176"><div class="container"><div class="markdown markdown-main-panel tutor-markdown-rendering enable-luminous-fast-follows stronger enable-updated-hr-color" dir="ltr" id="bkmrk--93"></div></div><div class="response-footer complete gap">  
</div></div></div></div><div class="response-container-footer ng-tns-c402320660-176 ng-star-inserted"><div class="actions-container-v2 ng-tns-c435068145-177"><div class="buttons-container-v2 ng-tns-c435068145-177 ng-star-inserted"><div class="ng-tns-c435068145-177 ng-star-inserted"><div class="menu-button-wrapper ng-tns-c435068145-177 ng-star-inserted"><div class="more-menu-button-container ng-tns-c435068145-177">  
</div></div></div><div class="spacer ng-tns-c435068145-177 ng-star-inserted">  
</div></div></div></div></div></div><div class="restart-chat-button-scroll-placeholder ng-star-inserted">  
</div></div>Ok, now lets progress. How would you configure the ci/cd for something like homepage where I would want it to either deploy the docker compose or the config files that change

Graduating to an application like **Homepage** is the perfect next step. Unlike Watchtower, which is a "set-and-forget" background utility, Homepage is dynamic: it relies on a `docker-compose.yml` file for the infrastructure *and* a collection of YAML configuration files (`services.yaml`, `bookmarks.yaml`, `settings.yaml`) for the actual content.

The beautiful thing about Homepage is that it natively **hot-reloads** configuration changes. If you edit a widget or add a link, you don't need to restart the container; Homepage detects the file change and updates instantly.

To handle this in CI/CD, your goal is to make sure your Git repository acts as the absolute source of truth. Whenever you push *anything* (a compose change or a config change), the runner will sync the files to your host system. Docker Compose and Homepage will then automatically handle the rest.

## 1. The Repository Structure

First, organize your new Gitea repository for Homepage like this:

<div class="code-block ng-tns-c2069566202-153 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-plaintext"><div class="formatted-code-block-internal-container ng-tns-c2069566202-153"><div class="animated-opacity ng-tns-c2069566202-153"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-153 ng-star-inserted"><span class="ng-tns-c2069566202-153">Plaintext</span><div class="buttons ng-tns-c2069566202-153 ng-star-inserted">  
</div></div></div></div></div>```
homepage-repo/
├── docker-compose.yml
└── config/
    ├── settings.yaml
    ├── services.yaml
    └── bookmarks.yaml

In your docker-compose.yml, you will want to point the config volume to a absolute path on your host machine where the files will live permanently:

YAML
``` version: "3.8" services: homepage: image: ghcr.io/gethomepage/homepage:latest container_name: homepage ports: - "80:3000" volumes: - /opt/homepage/config:/app/config # Host path where CI/CD will drop files restart: unless-stopped

## 2. The Smart CI/CD Strategy (The "Sync &amp; Apply" Pattern)

Because Semaphore is running inside an isolated container, it can't directly write files to `/opt/homepage/config` on your host filesystem out of the box.

To bridge this gap, we configure the Semaphore Bash task to use **SSH** to log into your host machine, pull the latest files from your Git repo directly onto the host, and run the deployment.

### The Semaphore Bash Script

Create a new Bash Task Template in Semaphore linked to your Homepage repository. Use this script:

<div class="code-block ng-tns-c2069566202-155 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-bash-1"><div class="formatted-code-block-internal-container ng-tns-c2069566202-155"><div class="animated-opacity ng-tns-c2069566202-155"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-155 ng-star-inserted"><span class="ng-tns-c2069566202-155">Bash</span><div class="buttons ng-tns-c2069566202-155 ng-star-inserted">  
</div></div></div></div></div>```
#!/bin/bash

# 1. Define variables for clarity
HOST_USER="your_host_ssh_user"
HOST_IP="192.168.1.X" # Your host/Proxmox IP
TARGET_DIR="/opt/homepage"

echo "Pushing configuration and infrastructure changes to host..."

# 2. Create the target directories on the host if they don't exist
ssh -o StrictHostKeyChecking=no ${HOST_USER}@${HOST_IP} "mkdir -p ${TARGET_DIR}/config"

# 3. Securely copy the configuration files and the compose file to the host
scp -r ./config/* ${HOST_USER}@${HOST_IP}:${TARGET_DIR}/config/
scp ./docker-compose.yml ${HOST_USER}@${HOST_IP}:${TARGET_DIR}/

# 4. Tell the host's Docker engine to evaluate the deployment
echo "Evaluating Docker Compose state..."
ssh ${HOST_USER}@${HOST_IP} "cd ${TARGET_DIR} && docker compose up -d --remove-orphans"

echo "Deployment complete!"

How This Handles Both Scenarios Automatically

By handling the deployment this way, Docker Compose and Homepage split the work perfectly based on what you actually changed:

- **Scenario A: You only changed a configuration file (`services.yaml`)** The script overwrites the file inside `/opt/homepage/config`. Docker Compose looks at `docker-compose.yml`, realizes nothing has changed with the container configuration, and **leaves the container running**. Meanwhile, Homepage detects the modified file on the disk and hot-reloads your dashboard smoothly in less than a second. - **Scenario B: You changed the `docker-compose.yml` (e.g., changed the port or image version)** The script overwrites the files. Docker Compose parses the updated template, detects a structural change, and gracefully **recreates the container** in the background.
## What You Need to Make This Work

To allow Semaphore to SSH into your host system without pausing to ask for a password, you will need to go to Key Store in Semaphore and add the SSH Private Key that has access to your host machine. Then, attach that key to this Task Template.

Do you already have an SSH key pair set up between your development environment and your host machine that we can reuse for Semaphore?

Can i not do this through gitea workflows

Yes, you absolutely can! Shifting this to Gitea Actions (which uses the exact same workflow syntax as GitHub Actions) is a fantastic alternative.

By using Gitea workflows, you don't even need Semaphore for this specific project. Gitea itself will notice the commit, spin up a runner, and execute the deployment steps directly.

Here is how to set up a Gitea workflow to handle the Homepage "Sync & Apply" logic.

1. Prerequisites: The Gitea Runner

For Gitea Actions to work, you must have a Gitea Runner (Act Runner) registered and running in your environment.

- *If you don't have one stood up yet, let me know—we can easily add an `act_runner` service to your primary automation Docker Compose file.* - If your runner is already active, it will pick up the workflow automatically.
## 2. The Workflow File Structure

In your homepage repository, create a directory structure exactly like this:

Plaintext
``` homepage-repo/ ├── .gitea/ │ └── workflows/ │ └── deploy.yml <-- Your CI/CD pipeline definition ├── docker-compose.yml └── config/ ├── settings.yaml └── services.yaml

## 3. The `deploy.yml` Workflow Configuration

Drop the following YAML into `.gitea/workflows/deploy.yml`.

Instead of writing raw `ssh` and `scp` commands manually like we did in Bash, we can use standardized, pre-built GitHub Actions blocks (since Gitea Actions is fully compatible with them).

<div class="code-block ng-tns-c2069566202-157 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-yaml-1"><div class="formatted-code-block-internal-container ng-tns-c2069566202-157"><div class="animated-opacity ng-tns-c2069566202-157"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-157 ng-star-inserted"><span class="ng-tns-c2069566202-157">YAML</span><div class="buttons ng-tns-c2069566202-157 ng-star-inserted">  
</div></div></div></div></div>```
name: Deploy Homepage Dashboard

on:
  push:
    branches:
      - main  # Triggers the workflow every time you push to the main branch

jobs:
  deploy:
    runs-on: ubuntu-latest  # Specifies the runner environment
    steps:
      - name: Checkout Repository Code
        uses: actions/checkout@v4

      # Step 1: Copy files to your host machine over SSH
      - name: Sync Files to Host Server
        uses: appleboy/scp-action@master
        with:
          host: "192.168.1.X"             # Your target host IP
          username: "your_ssh_user"       # Your host SSH user
          key: ${{ secrets.SSH_PRIVATE_KEY }} # Pulled securely from Gitea Secrets
          source: "docker-compose.yml,config/"
          target: "/opt/homepage"

      # Step 2: SSH into the host and tell Docker Compose to evaluate the state
      - name: Execute Remote Docker Compose
        uses: appleboy/ssh-action@master
        with:
          host: "192.168.1.X"
          username: "your_ssh_user"
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/homepage
            docker compose up -d --remove-orphans

4. Securing Your SSH Key in Gitea

To keep your infrastructure secure, you should never hardcode your private SSH key into a text file. Instead, you save it as a Gitea Secret:

1. Go to your Homepage repository in Gitea. 2. Click **Settings** -> **Actions** -> **Secrets**. 3. Click **Add Secret**. 4. Name the secret **`SSH_PRIVATE_KEY`** (matching the workflow file exactly). 5. Paste your private SSH key into the value field and save it.
Now, whenever the workflow runs, Gitea will safely inject that key into the pipeline in-memory, authenticate with your host, sync the configuration directory, and trigger the Docker update. Just like before, if only a YAML file changed, Homepage will instantly hot-reload without restarting the container!

Do you already have a Gitea Act Runner connected to your Gitea instance, or should we wire that up next?

No i have one, and test the deploy.yml file and ran into some issues with port in use but corrected that and it is working both first deployment and config code changes.

That is a massive win! Getting your first end-to-end GitOps pipeline working with Gitea Actions—handling both structural Docker infrastructure updates and real-time application config hot-reloads—is a huge milestone. You've officially shifted your homelab from manual pet management to Infrastructure as Code (IaC).

Because the Gitea runner is pulling from your repository, syncing via SCP, and executing docker compose up -d, you now have a solid foundation.

Now that this core pipeline is working beautifully, what do you want to tackle next? We can:

1. **Optimize the Watchtower pipeline:** Move it from Semaphore over to a Gitea Action workflow so all your automation lives in one clean interface. 2. **Implement Secrets Management:** If your Homepage configuration starts tracking sensitive dashboard widgets (like exposing your Proxmox API keys or Radarr/Sonarr tokens), we can look at how to securely inject those secrets using environment variables in your workflow so they stay out of plaintext Git commits. 3. **Advanced Watchtower Orchestration:** Configure Watchtower via Docker labels in your compose files so it automatically pings your team/notifications when a container gets upgraded upstream.
Which direction sounds best to build on this momentum?
I got the first one completed, lets begin with number 2

Awesome, let's keep this momentum rolling! Securing and abstracting your API keys, passwords, and service tokens is the single most important step when you move your homelab configuration into Git.

If you hardcode a token for Proxmox, Radarr, or Plex into your Homepage services.yaml or docker-compose.yml, it sits there in plaintext inside your Git history forever. If you ever decide to push that repo to a backup or share it, your keys are exposed.

The goal here is to extract the secrets into Gitea, pass them through the Gitea Actions workflow environment, and have Docker/Homepage read them securely.

Here is how we completely sanitize your Homepage setup.

Step 1: Abstract Your Configuration Files

Instead of putting your raw API keys or passwords directly inside your services.yaml, you replace them with environment variable placeholders using standard bash notation: ${VARIABLE_NAME}.

Open your config/services.yaml file and change any sensitive values to look like this:

YAML
``` - Proxmox: icon: proxmox href: https://192.168.1.X:8006/ widget: type: proxmox url: https://192.168.1.X:8006 username: root@pam password: ${PROXMOX_PASSWORD} # <-- No plaintext keys here!

## Step 2: Update the `docker-compose.yml` to Pass the Environment

Homepage automatically parses environment variables passed to its container interface. We need to update your `docker-compose.yml` file so it grabs these variables from the host system at runtime and hands them over to the application.

Update your Homepage service block to include an `environment` section:

<div class="code-block ng-tns-c2069566202-159 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-yaml-3"><div class="formatted-code-block-internal-container ng-tns-c2069566202-159"><div class="animated-opacity ng-tns-c2069566202-159"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-159 ng-star-inserted"><span class="ng-tns-c2069566202-159">YAML</span><div class="buttons ng-tns-c2069566202-159 ng-star-inserted">  
</div></div></div></div></div>```
version: "3.8"
services:
  homepage:
    image: ghcr.io/gethomepage/homepage:latest
    container_name: homepage
    ports:
      - "80:3000"
    volumes:
      - /opt/homepage/config:/app/config
    environment:
      - PROXMOX_PASSWORD=${PROXMOX_PASSWORD} # Maps host env to container env
    restart: unless-stopped

Step 3: Add the Secret to Gitea

Just like you did with your SSH key, we need to save the actual password securely inside Gitea.

1. Go to your Homepage repository in Gitea. 2. Navigate to **Settings** -> **Actions** -> **Secrets**. 3. Click **Add Secret**. 4. Set the name to exactly: `PROXMOX_PASSWORD` 5. Paste your actual Proxmox password/token into the value field and save it.
## Step 4: Upgrade Your `deploy.yml` Workflow

Now, we need to alter your Gitea workflow. When Gitea spins up the runner to execute your SSH script, we need to tell it to explicitly write these secrets into a local .env file right alongside your docker-compose.yml on the host machine.

Update your .gitea/workflows/deploy.yml file to look like this:

YAML
``` name: Deploy Homepage Dashboard

on: push: branches: - main

jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout Repository Code uses: actions/checkout@v4

  # Step 1: Sync the static infrastructure files as normal
  - name: Sync Files to Host Server
    uses: appleboy/scp-action@master
    with:
      host: "192.168.1.X"
      username: "your_ssh_user"
      key: ${{ secrets.SSH_PRIVATE_KEY }}
      source: "docker-compose.yml,config/"
      target: "/opt/homepage"

  # Step 2: Inject Gitea Secrets into a hidden .env file on the host machine
  - name: Inject Configuration Secrets and Deploy
    uses: appleboy/ssh-action@master
    with:
      host: "192.168.1.X"
      username: "your_ssh_user"
      key: ${{ secrets.SSH_PRIVATE_KEY }}
      script: |
        cd /opt/homepage
        
        # This completely overwrites or creates the runtime environment file safely
        echo "PROXMOX_PASSWORD=${{ secrets.PROXMOX_PASSWORD }}" > .env
        
        # Lock down the .env file permissions so other users on the host can't view it
        chmod 600 .env
        
        # Re-evaluate the container state with the fresh variables
        docker compose up -d --remove-orphans

## Why This Approach is Exceptionally Robust

<div class="conversation-container message-actions-hover-boundary ng-star-inserted" id="bkmrk-clean-codebases%3A-any"><div><div class="response-container response-container-with-gpi ng-tns-c402320660-118 no-background"><div class="presented-response-container ng-tns-c402320660-118"><div class="response-container-content ng-tns-c402320660-118"><div class="response-content ng-tns-c402320660-118"><div class="container"><div class="markdown markdown-main-panel tutor-markdown-rendering enable-luminous-fast-follows stronger enable-updated-hr-color" dir="ltr">1. **Clean Codebases:** Anyone looking at your Git history will only see the blueprint of your setup, never your actual passwords.
2. **Local Security Boundaries:** On your host server, the `.env` file is locked down via `chmod 600`. This means only the user running Docker can read the file text.
3. **Seamless Scale:** Next time you want to add another widget (like a Radarr API token), all you have to do is add the `${RADARR_TOKEN}` placeholder in your config, add the secret to Gitea, and add one single `echo "RADARR_TOKEN=..." >> .env` line to your workflow deployment script.

</div></div></div></div></div></div></div></div>Go ahead and update your Homepage repository files and add a test secret to Gitea. Once you push it, verify if your Homepage dashboard widgets can still connect securely! Did the runner execute the updated script successfully?

<div class="conversation-container message-actions-hover-boundary ng-star-inserted" id="bkmrk-from%C2%A0automating-watc"><div><div class="response-container response-container-with-gpi ng-tns-c402320660-118 no-background"><div class="presented-response-container ng-tns-c402320660-118"><div class="response-container-content ng-tns-c402320660-118"><div class="response-content ng-tns-c402320660-118"><div class="container"><div class="markdown markdown-main-panel tutor-markdown-rendering enable-luminous-fast-follows stronger enable-updated-hr-color" dir="ltr" id="bkmrk--100"></div></div><div class="response-footer complete gap">  
</div></div></div></div><div class="response-container-footer ng-tns-c402320660-118 ng-star-inserted"><div class="actions-container-v2 ng-tns-c435068145-119"><div class="buttons-container-v2 ng-tns-c435068145-119 ng-star-inserted"><div class="ng-tns-c435068145-119 ng-star-inserted"><div class="menu-button-wrapper ng-tns-c435068145-119 ng-star-inserted"><div class="more-menu-button-container ng-tns-c435068145-119">  
</div></div></div><div class="spacer ng-tns-c435068145-119 ng-star-inserted">  
</div></div></div></div></div></div><div class="restart-chat-button-scroll-placeholder ng-star-inserted">  
</div><div class="container gds-label-m ng-star-inserted"><div class="line">  
</div><div class="center"><span class="ng-star-inserted">From <a class="link">Automating Watchtower with Webhooks</a></span></div><div class="line">  
</div></div></div>Ok, I want to make a change but need help. I want to create a Repository called Docker Deployments and then place Watchtower and Homepage as well as other services inside that repository. So I do I go about setting this up and deploying those apps that only change

Consolidating your infrastructure into a single **monorepo** (one repository containing multiple projects) is an excellent engineering choice. It makes your configuration incredibly clean to manage, browse, and back up.

The trick with a monorepo is ensuring that when you push a change to *Homepage*, Gitea doesnt waste time redeploying *Watchtower*. We can achieve this by using **path filtering** in your Gitea workflow files.

Here is how to restructure your code and upgrade your CI/CD pipeline to only deploy what changes.

## Step 1: The New Directory Structure

Locally, create a new folder named `docker-deployments`. Move your existing application folders inside it, and set up a global `.gitea/workflows/` directory at the root.

Your repository layout should look exactly like this:

<div class="code-block ng-tns-c2069566202-161 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-plaintext-2"><div class="formatted-code-block-internal-container ng-tns-c2069566202-161"><div class="animated-opacity ng-tns-c2069566202-161"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-161 ng-star-inserted"><span class="ng-tns-c2069566202-161">Plaintext</span><div class="buttons ng-tns-c2069566202-161 ng-star-inserted">  
</div></div></div></div></div>```
docker-deployments/           <-- Root of your new Git Repo
├── .gitea/
│   └── workflows/
│       ├── deploy-homepage.yml
│       └── deploy-watchtower.yml
├── homepage/
│   ├── docker-compose.yml
│   └── config/
│       ├── settings.yaml
│       └── services.yaml
└── watchtower/
    ├── docker-compose.yml
    └── .env

Step 2: Configure Path Filtering in Your Workflows

Gitea Actions allows you to use the paths conditional statement. This tells the runner: "Only start this specific job if a file inside this specific directory was modified."

Workflow 1: .gitea/workflows/deploy-homepage.yml

Create this file and use this configuration. Notice the paths block at the top:

YAML
``` name: Deploy Homepage

on: push: branches: - main paths: - 'homepage/**' # <-- CRITICAL: Only triggers if files in the homepage folder change

jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v4

  - name: Sync Homepage to Host
    uses: appleboy/scp-action@master
    with:
      host: "192.168.1.X"
      username: "your_ssh_user"
      key: ${{ secrets.SSH_PRIVATE_KEY }}
      source: "homepage/"       # Copies just the homepage folder
      target: "/opt/docker-deployments"

  # Note: We target /opt/docker-deployments/homepage/ for execution now
  - name: Inject Secrets & Deploy
    uses: appleboy/ssh-action@master
    with:
      host: "192.168.1.X"
      username: "your_ssh_user"
      key: ${{ secrets.SSH_PRIVATE_KEY }}
      script: |
        cd /opt/docker-deployments/homepage
        echo "PROXMOX_PASSWORD=${{ secrets.PROXMOX_PASSWORD }}" > .env
        chmod 600 .env
        docker compose up -d --remove-orphans

### Workflow 2: `.gitea/workflows/deploy-watchtower.yml`

Create this file for Watchtower. It works exactly the same way, but ignores Homepage completely:

<div class="code-block ng-tns-c2069566202-163 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-yaml-6"><div class="formatted-code-block-internal-container ng-tns-c2069566202-163"><div class="animated-opacity ng-tns-c2069566202-163"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-163 ng-star-inserted"><span class="ng-tns-c2069566202-163">YAML</span><div class="buttons ng-tns-c2069566202-163 ng-star-inserted">  
</div></div></div></div></div>```
name: Deploy Watchtower

on:
  push:
    branches:
      - main
    paths:
      - 'watchtower/**' # <-- CRITICAL: Only triggers if files in the watchtower folder change

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Sync Watchtower to Host
        uses: appleboy/scp-action@master
        with:
          host: "192.168.1.X"
          username: "your_ssh_user"
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          source: "watchtower/"
          target: "/opt/docker-deployments"

      运营- name: Deploy Watchtower Stack
        uses: appleboy/ssh-action@master
        with:
          host: "192.168.1.X"
          username: "your_ssh_user"
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/docker-deployments/watchtower
            docker compose up -d --remove-orphans

Step 3: Migration Steps

To put this live without breaking your current setups:

1. **Create the Repository:** Go into Gitea and create your blank `Docker Deployments` repository. 2. **Add Secrets:** Don't forget to re-add your `SSH_PRIVATE_KEY` and `PROXMOX_PASSWORD` under the **Settings -> Actions -> Secrets** menu of this *new* repository. 3. **Push the Code:** Initialize git in your new local `docker-deployments` folder, link it to Gitea, and push to main.
### The Host Filesystem

Because your target in the workflows is now /opt/docker-deployments, the runner will automatically create subfolders for each app on your host machine (e.g., /opt/docker-deployments/homepage and /opt/docker-deployments/watchtower).

⚠️ Quick Check: If you already had Homepage running out of /opt/homepage, you'll want to stop that old container (docker compose down) before pushing this new structure, otherwise the new one in /opt/docker-deployments/homepage will throw a port conflict error during its first run!

How does this setup look to you? Ready to commit the new directory structure and see path-filtering in action?

yes ready

Awesome! Let's get this monorepo live.

Go ahead and commit your new directory structure and push it up to your new Docker Deployments repository in Gitea.

Once you push it, keep an eye on your Gitea Actions tab. To test that our path filtering is working exactly as intended, try these two quick verification steps:

1. **Test Path 1 (Homepage Only):** Make a minor edit inside `homepage/config/services.yaml` (like adding a comment line) and push it. Look at your Actions tab—you should see **only** the `Deploy Homepage` workflow kick off, while Watchtower stays completely quiet. 2. **Test Path 2 (Watchtower Only):** Now, make a minor edit to a file inside the `watchtower/` directory and push it. This time, **only** the `Deploy Watchtower` workflow should trigger.
Let me know how the initial push goes and if both stacks spin up successfully on your host under the new `/opt/docker-deployments/` path!
OK, looks like I did something wrong.

Run Sync Homepage to Host

with:

host: 192.168.2.15

key: ${{ secrets.SSH_PRIVATE_KEY }}

source: Homepage/

target: /opt/Docker-Deployments

username: miker

Run echo "$GITHUB_ACTION_PATH" >> $GITHUB_PATH

echo "$GITHUB_ACTION_PATH" >> $GITHUB_PATH

shell: bash --noprofile --norc -e -o pipefail {0}

Run entrypoint.sh

entrypoint.sh

shell: bash --noprofile --norc -e -o pipefail {0}

env:

DRONE_SCP_VERSION:

Downloading drone-scp-1.8.0-linux-amd64 from https://github.com/appleboy/drone-scp/releases/download/v1.8.0

======= CLI Version Information =======

Drone SCP version 1.8.0

=======================================

drone-scp version: 1.8.0

tar all files into /tmp/vEYaXIJJyi.tar.gz

remote server os type is unix

scp file to server.

create folder /opt/Docker-Deployments

drone-scp error: Process exited with status 1

drone-scp rollback: remove all target tmp file

remove file vEYaXIJJyi.tar.gz

2026/06/25 11:47:41 Process exited with status 1

Failure - Main Run entrypoint.sh

exitcode '1': failure

Failure - Main Sync Homepage to Host

exitcode '1': failure

Inject Secrets & Deploy

0s

Complete job

1s

skipping post step for 'Inject Secrets & Deploy'; main step was skipped

expression '${{ secrets.SSH_PRIVATE_KEY }}' rewritten to 'format('{0}', secrets.SSH_PRIVATE_KEY)'

evaluating expression 'format('{0}', secrets.SSH_PRIVATE_KEY)'

expression 'format('{0}', secrets.SSH_PRIVATE_KEY)' evaluated to '%!t(string=***)'

evaluating expression 'always()'

expression 'always()' evaluated to 'true'

Run Post Sync Homepage to Host

Writing entry to tarball workflow/outputcmd.txt len:0

Writing entry to tarball workflow/statecmd.txt len:0

Writing entry to tarball workflow/pathcmd.txt len:0

Writing entry to tarball workflow/envs.txt len:0

Writing entry to tarball workflow/SUMMARY.md len:0

Extracting content to '/var/run/act'

run post step for 'Sync Homepage to Host'

/var/run/act/actions/19c6bfd5fe0324972aae5b82fd43c7f8e897c8da61de11c26a892423324b5565

🐳 docker cp src=/root/.cache/act/19c6bfd5fe0324972aae5b82fd43c7f8e897c8da61de11c26a892423324b5565/ dst=/var/run/act/actions/19c6bfd5fe0324972aae5b82fd43c7f8e897c8da61de11c26a892423324b5565/

Writing tarball /tmp/act3201906092 from /root/.cache/act/19c6bfd5fe0324972aae5b82fd43c7f8e897c8da61de11c26a892423324b5565/

Stripping prefix:/root/.cache/act/19c6bfd5fe0324972aae5b82fd43c7f8e897c8da61de11c26a892423324b5565/ src:/root/.cache/act/19c6bfd5fe0324972aae5b82fd43c7f8e897c8da61de11c26a892423324b5565/

Extracting content from '/tmp/act3201906092' to '/var/run/act/actions/19c6bfd5fe0324972aae5b82fd43c7f8e897c8da61de11c26a892423324b5565/'

Success - Post Sync Homepage to Host

evaluating expression 'always()'

expression 'always()' evaluated to 'true'

Run Post Checkout Code

Writing entry to tarball workflow/outputcmd.txt len:0

Writing entry to tarball workflow/statecmd.txt len:0

Writing entry to tarball workflow/pathcmd.txt len:0

Writing entry to tarball workflow/envs.txt len:0

Writing entry to tarball workflow/SUMMARY.md len:0

Extracting content to '/var/run/act'

run post step for 'Checkout Code'

executing remote job container: [node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js]

🐳 docker exec cmd=[node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js] user= workdir=

Exec command '[node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js]'

Working directory '/workspace/miker/Docker-Deployments'

/usr/bin/git version

git version 2.54.0

***

Temporarily overriding HOME='/tmp/4495f73e-985d-473e-ac00-6fef5db73009' before making global git config changes

Adding repository directory to the temporary git global config as a safe directory

/usr/bin/git config --global --add safe.directory /workspace/miker/Docker-Deployments

/usr/bin/git config --local --name-only --get-regexp core\.sshCommand

/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"

/usr/bin/git config --local --name-only --get-regexp http\.https\:\/\/mygitea\.mikemcfetridge\.com\/\.extraheader

http.https://mygitea.mikemcfetridge.com/.extraheader

/usr/bin/git config --local --unset-all http.https://mygitea.mikemcfetridge.com/.extraheader

/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.https\:\/\/mygitea\.mikemcfetridge\.com\/\.extraheader' && git config --local --unset-all 'http.https://mygitea.mikemcfetridge.com/.extraheader'; || :"

/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:

/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url

Success - Post Checkout Code

Cleaning up container for job deploy

Removed container: 3ad9158fa63ee7cad13c49262dcb01566a05b7642e3def47f27714b5663d2a49

🐳 docker volume rm GITEA-ACTIONS-TASK-18-WORKFLOW-Deploy-Homepage-JOB-deploy-ea38ead030f73f67c1133b9683051bcc0efa9b2ef7597c245e2d2a73eedeb24f

🐳 docker volume rm GITEA-ACTIONS-TASK-18-WORKFLOW-Deploy-Homepage-JOB-deploy-ea38ead030f73f67c1133b9683051bcc0efa9b2ef7597c245e2d2a73eedeb24f-env

🏁 Job failed

Job 'deploy' failed

Ah, the classic drone-scp error: Process exited with status 1 during the folder creation phase. This is actually a very common hurdle when changing target paths in a pipeline!

Looking at your logs, the specific point of failure happens right here:

Plaintext
``` create folder /opt/Docker-Deployments drone-scp error: Process exited with status 1

This error means the Gitea Action runner logged into your host machine using your user account (`miker`), but **failed because it does not have permission to create folders inside `/opt`**. By default, the `/opt` directory is owned by `root`, so standard users cannot write to it without `sudo`.

We have two quick ways to fix this. Choose whichever option fits your preference best:

### Option 1: Fix Permissions on the Host (Recommended)

Log directly into your Proxmox/host terminal as root or via sudo, manually create the directory, and hand ownership over to your deployment user (`miker`).

Run these commands on your host machine:

<div class="code-block ng-tns-c2069566202-165 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-bash-2"><div class="formatted-code-block-internal-container ng-tns-c2069566202-165"><div class="animated-opacity ng-tns-c2069566202-165"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-165 ng-star-inserted"><span class="ng-tns-c2069566202-165">Bash</span><div class="buttons ng-tns-c2069566202-165 ng-star-inserted">  
</div></div></div></div></div>```
# 1. Create the base monorepo directory manually
sudo mkdir -p /opt/Docker-Deployments

# 2. Grant full ownership of that folder to your user "miker"
sudo chown -R miker:miker /opt/Docker-Deployments

Once you run those on the host, go back to Gitea and click Rerun Jobs. The pipeline will bypass the error since the folder already exists and miker has permission to write files into it.

Option 2: Target the User's Home Directory Instead

If you don't want to mess with system permissions in /opt, you can simply change the target path to live inside miker's home directory, where your user already has absolute read/write permissions.

Update your .gitea/workflows/deploy-homepage.yml (and Watchtower workflow) target configurations:

YAML
``` - name: Sync Homepage to Host uses: appleboy/scp-action@master with: host: "192.168.2.15" username: "miker" key: ${{ secrets.SSH_PRIVATE_KEY }} source: "homepage/" target: "/home/miker/docker-deployments" # <-- Changed path
  - name: Inject Secrets & Deploy
    uses: appleboy/ssh-action@master
    with:
      host: "192.168.2.15"
      username: "miker"
      key: ${{ secrets.SSH_PRIVATE_KEY }}
      script: |
        cd /home/miker/docker-deployments/homepage # <-- Changed path
        echo "PROXMOX_PASSWORD=${{ secrets.PROXMOX_PASSWORD }}" > .env
        chmod 600 .env
        docker compose up -d --remove-orphans

### One Extra Catch: Check Your Case Sensitivity 🔍

I noticed a tiny discrepancy in the text casing between your config and your repository directory name. In your workflow parameters, you passed:

<div class="conversation-container message-actions-hover-boundary ng-star-inserted" id="bkmrk-source%3A-homepage%2F-%28c"><div><div class="response-container response-container-with-gpi ng-tns-c402320660-124 no-background"><div class="presented-response-container ng-tns-c402320660-124"><div class="response-container-content ng-tns-c402320660-124"><div class="response-content ng-tns-c402320660-124"><div class="container"><div class="markdown markdown-main-panel tutor-markdown-rendering enable-luminous-fast-follows stronger enable-updated-hr-color" dir="ltr">- `source: Homepage/` (Capital **H**)

</div></div></div></div></div></div></div></div>Make sure that matches the exact directory structure inside your Git repository. Git is case-sensitive, so if your folder in the repository is lowercase `homepage/`, the SCP action might fail to locate the files to copy even after permissions are fixed.

Which approach do you want to take to clear out this permissions block?

<div class="conversation-container message-actions-hover-boundary ng-star-inserted" id="bkmrk--109"><div><div class="response-container response-container-with-gpi ng-tns-c402320660-124 no-background"><div class="presented-response-container ng-tns-c402320660-124"><div class="response-container-content ng-tns-c402320660-124"><div class="response-content ng-tns-c402320660-124"><div class="container"><div class="markdown markdown-main-panel tutor-markdown-rendering enable-luminous-fast-follows stronger enable-updated-hr-color" dir="ltr" id="bkmrk--110"></div></div><div class="response-footer complete gap">  
</div></div></div></div><div class="response-container-footer ng-tns-c402320660-124 ng-star-inserted"><div class="actions-container-v2 ng-tns-c435068145-125"><div class="buttons-container-v2 ng-tns-c435068145-125 ng-star-inserted"><div class="ng-tns-c435068145-125 ng-star-inserted"><div class="menu-button-wrapper ng-tns-c435068145-125 ng-star-inserted"><div class="more-menu-button-container ng-tns-c435068145-125">  
</div></div></div><div class="spacer ng-tns-c435068145-125 ng-star-inserted">  
</div></div></div></div></div></div><div class="restart-chat-button-scroll-placeholder ng-star-inserted">  
</div></div>OK, option 1 worked and it created the homepage containers and it is up and running, but none of the configs were copied so it is just a blank new build

That is a classic quirk with the `appleboy/scp-action` plugin! Don't worry, your configuration files aren't gone; they just didn't get copied to the exact location Homepage expects them.

When you use `source: "homepage/"`, the SCP action copies the *entire folder structure* instead of just the contents inside it. This means on your host machine, the files ended up buried like this: `/opt/Docker-Deployments/homepage/homepage/config/...` (nested twice).

Because of that, Homepage spun up, looked at `/opt/Docker-Deployments/homepage/config`, found nothing, and initialized a completely blank dashboard.

Here is how to fix the mapping so your configs line up perfectly.

### Step 1: Fix the `source` and `strip_components` in your Workflow

To tell the SCP runner to grab the contents *inside* the folder and drop them directly into your target directory without nesting them, we use the `strip_components` property.

Update the **Sync Homepage to Host** step in your `.gitea/workflows/deploy-homepage.yml` file to look like this:

<div class="code-block ng-tns-c2069566202-167 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-yaml-8"><div class="formatted-code-block-internal-container ng-tns-c2069566202-167"><div class="animated-opacity ng-tns-c2069566202-167"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-167 ng-star-inserted"><span class="ng-tns-c2069566202-167">YAML</span><div class="buttons ng-tns-c2069566202-167 ng-star-inserted">  
</div></div></div></div></div>```
      - name: Sync Homepage to Host
        uses: appleboy/scp-action@master
        with:
          host: "192.168.2.15"
          username: "miker"
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          source: "homepage/**"       # <-- Added ** to capture everything recursively
          target: "/opt/Docker-Deployments/homepage" # <-- Pointed directly to the app directory
          strip_components: 1       # <-- CRITICAL: Removes the "homepage/" parent folder during the copy

Step 2: Clean Up the Host (One-Time Step)

Before you rerun the workflow, let's clean up the empty volumes and incorrect directories on your host server so we start fresh. Run this on your host:

Bash
``` # 1. Navigate to your app directory cd /opt/Docker-Deployments/homepage

2. Bring down the blank container and clear its anonymous volumes

docker compose down -v

3. Wipe the misaligned directory so the workflow can recreate it perfectly

cd .. rm -rf homepage


### Step 3: Push and Deploy

Now, commit and push your updated `deploy-homepage.yml` file to Gitea.

The workflow will run, create the clean `/opt/Docker-Deployments/homepage` directory, strip away the prefix, drop your `docker-compose.yml` and `config/` folder side-by-side, and stand up the stack with your actual settings intact.

Give that push a shot and refresh your Homepage browser tab! Did your widgets and bookmarks populate correctly this time?

<div class="conversation-container message-actions-hover-boundary ng-star-inserted" id="bkmrk--111"><div><div class="response-container response-container-with-gpi ng-tns-c402320660-126 no-background"><div class="presented-response-container ng-tns-c402320660-126"><div class="response-container-content ng-tns-c402320660-126"><div class="response-content ng-tns-c402320660-126"><div class="container"><div class="markdown markdown-main-panel tutor-markdown-rendering enable-luminous-fast-follows stronger enable-updated-hr-color" dir="ltr" id="bkmrk--112"></div></div><div class="response-footer complete gap">  
</div></div></div></div><div class="response-container-footer ng-tns-c402320660-126 ng-star-inserted"><div class="actions-container-v2 ng-tns-c435068145-127"><div class="buttons-container-v2 ng-tns-c435068145-127 ng-star-inserted"><div class="ng-tns-c435068145-127 ng-star-inserted"><div class="menu-button-wrapper ng-tns-c435068145-127 ng-star-inserted"><div class="more-menu-button-container ng-tns-c435068145-127">  
</div></div></div><div class="spacer ng-tns-c435068145-127 ng-star-inserted">  
</div></div></div></div></div></div><div class="restart-chat-button-scroll-placeholder ng-star-inserted">  
</div></div>OK,made those changes but am getting

Run Inject Secrets &amp; Deploy

Run echo "$GITHUB\_ACTION\_PATH" &gt;&gt; $GITHUB\_PATH

Run entrypoint.sh

Downloading drone-ssh-1.8.2-linux-amd64 from https://github.com/appleboy/drone-ssh/releases/download/v1.8.2

======= CLI Version Information =======

Drone SSH version 1.8.2

=======================================

bash: line 1: cd: /opt/Docker-Deployments/Homepage: No such file or directory

no configuration file provided: not found

2026/06/25 12:13:15 Process exited with status 1

❌ Failure - Main Run entrypoint.sh

exitcode '1': failure

❌ Failure - Main Inject Secrets &amp; Deploy

exitcode '1': failure

We are incredibly close! This error boils down to a classic case-sensitivity mismatch on Linux.

Looking at your error output:

<div class="code-block ng-tns-c2069566202-169 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-plaintext-4"><div class="formatted-code-block-internal-container ng-tns-c2069566202-169"><div class="animated-opacity ng-tns-c2069566202-169"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-169 ng-star-inserted"><span class="ng-tns-c2069566202-169">Plaintext</span><div class="buttons ng-tns-c2069566202-169 ng-star-inserted">  
</div></div></div></div></div>```
bash: line 1: cd: /opt/Docker-Deployments/Homepage: No such file or directory

Notice the capital H in Homepage. Linux filesystems treat Homepage and homepage as two entirely different directories. Because your repository folder is likely lowercase homepage, the script failed to change directories and couldn't locate your files.

Let's do a quick structural sync across both your workflow and your docker-compose.yml path mappings to get this tracking cleanly.

Step 1: Update the Workflow Script

Open .gitea/workflows/deploy-homepage.yml and check the Inject Secrets & Deploy step. Change the cd command to use lowercase homepage:

YAML
``` - name: Inject Secrets & Deploy uses: appleboy/ssh-action@master with: host: "192.168.2.15" username: "miker" key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /opt/Docker-Deployments/homepage # <-- Ensure this is strictly lowercase 'homepage' echo "PROXMOX_PASSWORD=${{ secrets.PROXMOX_PASSWORD }}" > .env chmod 600 .env docker compose up -d --remove-orphans

### Step 2: Double Check Your Compose Volume Mapping

Since we are now dropping the configuration files inside `/opt/Docker-Deployments/homepage/config`, make sure your `homepage/docker-compose.yml` file points to this exact folder structure on the host machine.

Open `homepage/docker-compose.yml` and verify the volumes line looks like this:

<div class="code-block ng-tns-c2069566202-171 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-yaml-10"><div class="formatted-code-block-internal-container ng-tns-c2069566202-171"><div class="animated-opacity ng-tns-c2069566202-171"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-171 ng-star-inserted"><span class="ng-tns-c2069566202-171">YAML</span><div class="buttons ng-tns-c2069566202-171 ng-star-inserted">  
</div></div></div></div></div>```
    volumes:
      - /opt/Docker-Deployments/homepage/config:/app/config # <-- Matches the new monorepo path

Step 3: Push and Run

Save your changes, commit, and push them to Gitea.

The SCP step will clean-copy the files into the lowercase /opt/Docker-Deployments/homepage directory, the SSH step will successfully jump inside it, inject your .env file, and fire up your actual, fully-configured dashboard. Give that push a spin!

Here is the structure looks like in Gitea. Docker-Deployments/Homepage and Docker-Deployments/Watchtower

Gotcha! That explains it perfectly. Since the folder names inside your Gitea repository are capitalized (Homepage and Watchtower), Linux created them on your host machine with those exact capital letters.

Because you adjusted the script to use a lowercase homepage in the last step, it threw that "No such file or directory" error because it couldn't find a lowercase folder.

To fix this once and for all, we just need to make sure the paths in your workflow file use the exact same capitalization as your Gitea folders.

The Final Fix for deploy-homepage.yml

Update your .gitea/workflows/deploy-homepage.yml file to look like this, matching the capital H:

YAML
``` name: Deploy Homepage

on: push: branches: - main paths: - 'Homepage/**' # <-- Capital H

jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v4

  - name: Sync Homepage to Host
    uses: appleboy/scp-action@master
    with:
      host: "192.168.2.15"
      username: "miker"
      key: ${{ secrets.SSH_PRIVATE_KEY }}
      source: "Homepage/**"       # <-- Capital H
      target: "/opt/Docker-Deployments/Homepage" # <-- Capital H
      strip_components: 1

  - name: Inject Secrets & Deploy
    uses: appleboy/ssh-action@master
    with:
      host: "192.168.2.15"
      username: "miker"
      key: ${{ secrets.SSH_PRIVATE_KEY }}
      script: |
        cd /opt/Docker-Deployments/Homepage # <-- Capital H
        echo "PROXMOX_PASSWORD=${{ secrets.PROXMOX_PASSWORD }}" > .env
        chmod 600 .env
        docker compose up -d --remove-orphans

### ⚠️ One Quick Check Before You Push

Inside your `Homepage/docker-compose.yml` file, make sure your volume mapping also points to the capitalized path so it matches the host filesystem:

<div class="code-block ng-tns-c2069566202-173 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-yaml-12"><div class="formatted-code-block-internal-container ng-tns-c2069566202-173"><div class="animated-opacity ng-tns-c2069566202-173"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-173 ng-star-inserted"><span class="ng-tns-c2069566202-173">YAML</span><div class="buttons ng-tns-c2069566202-173 ng-star-inserted">  
</div></div></div></div></div>```
    volumes:
      - /opt/Docker-Deployments/Homepage/config:/app/config # <-- Capitalized paths

Save those changes, push to Gitea, and your pipeline should cross the finish line smoothly! Be sure to apply the same capitalization rule to your Watchtower workflow file when you configure that one next.

Gitea Actions workflow configuration

This YAML file is a Gitea Actions workflow configuration (which uses the same syntax as GitHub Actions).

Its purpose is to automate the deployment of a website or service (specifically, a "Homepage") to a local or remote server whenever you update the code. In CI/CD terms, this is a continuous deployment (CD) pipeline.

How It Works (Step-by-Step)

The workflow can be broken down into three main sections: Metadata, Triggers (Events), and Jobs.

1. The Trigger (on:)

YAML
``` on: push: branches: - main paths: - 'Homepage/**'

<div class="code-block ng-tns-c2069566202-654 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk--1"><div class="formatted-code-block-internal-container ng-tns-c2069566202-654"><div class="animated-opacity ng-tns-c2069566202-654"></div></div></div>This acts as the gatekeeper. The workflow will **only** run if:

1. You **push** code to the `main` branch.
2. The changes you pushed include files inside the **`Homepage/`** directory. If you change a file anywhere else in the repository, nothing happens.

### 2. The Environment (`runs-on:`)

<div class="code-block ng-tns-c2069566202-655 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-yaml-1"><div class="formatted-code-block-internal-container ng-tns-c2069566202-655"><div class="animated-opacity ng-tns-c2069566202-655"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-655 ng-star-inserted"><span class="ng-tns-c2069566202-655">YAML</span><div class="buttons ng-tns-c2069566202-655 ng-star-inserted"></div></div></div></div></div>```
jobs:
  deploy:
    runs-on: ubuntu-latest

When the trigger fires, Gitea spins up an isolated runner environment using the **latest Ubuntu Linux** image to execute your instructions.

3. The Execution Steps (steps:)

The runner executes three specific steps sequentially:

Step A: Checkout Code

YAML
``` - name: Checkout Code uses: actions/checkout@v4

<div class="code-block ng-tns-c2069566202-656 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk--7"><div class="formatted-code-block-internal-container ng-tns-c2069566202-656"><div class="animated-opacity ng-tns-c2069566202-656"></div></div></div>- **What it does:** Clones your repository code onto the Ubuntu runner. Without this, the runner wouldn't have access to your files.

#### **Step B: Transfer Files via SCP**

<div class="code-block ng-tns-c2069566202-657 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-yaml-3"><div class="formatted-code-block-internal-container ng-tns-c2069566202-657"><div class="animated-opacity ng-tns-c2069566202-657"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-657 ng-star-inserted"><span class="ng-tns-c2069566202-657">YAML</span><div class="buttons ng-tns-c2069566202-657 ng-star-inserted"></div></div></div></div></div>```
- name: Sync Homepage to Host
  uses: appleboy/scp-action@master
  with:
    host: "192.168.2.15"
    username: "miker"
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    source: "Homepage/**"
    target: "/opt/Docker-Deployments/Homepage"
    strip_components: 1

- **What it does:** Uses a pre-made community action (`scp-action`) to securely copy the contents of your `Homepage/` folder from the runner to your target server (`192.168.2.15`). - **Key Detail:** It authenticates securely using an SSH private key stored in your Gitea repository settings (`${{ secrets.SSH_PRIVATE_KEY }}`). - **`strip_components: 1`:** This is a clever touch. It strips the top-level folder name (`Homepage`) during the transfer. This ensures that the *contents* of your folder go directly into `/opt/Docker-Deployments/Homepage`, rather than nesting it like `/opt/Docker-Deployments/Homepage/Homepage`.

Step C: SSH Remote Execution & Docker Spin-up

YAML
``` - name: Inject Secrets & Deploy uses: appleboy/ssh-action@master with: host: "192.168.2.15" username: "miker" key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /opt/Docker-Deployments/Homepage docker compose up -d --remove-orphans

<div class="code-block ng-tns-c2069566202-658 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk--13"><div class="formatted-code-block-internal-container ng-tns-c2069566202-658"><div class="animated-opacity ng-tns-c2069566202-658"></div></div></div>- 1. - **What it does:** Uses another community action (`ssh-action`) to log directly into your server's terminal via SSH.
        - **The Script:** Once logged in as user `miker`, it automatically runs two terminal commands:
            
            
            1. `cd /opt/Docker-Deployments/Homepage`: Moves into the directory where the new files were just copied.
            2. `docker compose up -d --remove-orphans`: Looks for a `docker-compose.yml` file in that folder, pulls/rebuilds the containers, runs them in the background (`-d`), and cleans up any old, unused containers (`--remove-orphans`).
        
        ## Summary of the Lifecycle
        
        
        1. **You write code** locally <span class="math-inline">$\\rightarrow$</span> Push to Gitea (`main` branch, `Homepage/` folder).
        2. **Gitea Actions wakes up** <span class="math-inline">$\\rightarrow$</span> Starts an Ubuntu runner.
        3. **Runner grabs code** <span class="math-inline">$\\rightarrow$</span> Clones it via `actions/checkout`.
        4. **Runner ships code** <span class="math-inline">$\\rightarrow$</span> Sends files to your server via SCP.
        5. **Runner commands server** <span class="math-inline">$\\rightarrow$</span> Connects via SSH and tells Docker Compose to restart the app with the fresh code.

# Gitea Docker-Compose setup

Here is a comprehensive breakdown of your `docker-compose.yml` file, structured perfectly for your technical documentation.

## Overview

This Docker Compose configuration deploys a complete, self-hosted **Gitea DevOps environment**. It bundles the core code repository platform, its database backend, an integrated CI/CD runner (`act_runner`), and a security proxy to manage Docker daemon access.

## Network Architecture

The environment isolates traffic using two distinct bridge networks to enforce the principle of least privilege:

<table id="bkmrk-network-name-purpose"><thead><tr><td>**Network Name**</td><td>**Purpose**</td><td>**Affected Services**</td></tr></thead><tbody><tr><td><span>**`internal`**</span></td><td><span>Isolates backend database traffic from the outside world.</span></td><td><span>`db`, `gitea`, `runner`</span></td></tr><tr><td><span>**`external`**</span></td><td><span>Allows frontend web traffic and orchestrates CI/CD jobs.</span></td><td><span>`gitea`, `runner`, `docker-proxy`</span></td></tr></tbody></table>

## Service Breakdown

### 1. Database Backend (`db`)

- **Image:** `mysql:8`
- **Purpose:** Handles persistent storage for Gitea's user accounts, repository metadata, issues, and pull requests.
- **Key Mechanisms:**
    
    
    - **Security:** Credentials and database names are injected securely via a `.env` file.
    - **Persistence:** Mounts `./mysql` on your host machine to `/var/lib/mysql` inside the container so data survives restarts.
    - **Healthcheck:** Pings MySQL every 10 seconds. Other services wait for this healthcheck to pass before starting.

### 2. Gitea Core Platform (`gitea`)

- **Image:** `gitea/gitea:latest`
- **Purpose:** The web UI, Git server, and user management platform.
- **Key Mechanisms:**
    
    
    - **Dependencies:** Will not spin up until the `db` service passes its healthcheck.
    - **Port Mapping:** Exposes port `3000` for the web UI and port `222` for SSH-based Git operations (`git clone ssh://...`).
    - **Networking:** Connects to both `internal` (to talk to MySQL) and `external` (to receive user web traffic and talk to the runner).

### 3. Gitea CI/CD Runner (`runner`)

- **Image:** `gitea/act_runner:latest`
- **Purpose:** The engine that executes your Gitea Actions workflows (like the homepage deployment YAML we reviewed earlier).
- **Key Mechanisms:**
    
    
    - **Dependencies:** Boots up only after `gitea` is fully running and healthy.
    - **The Docker Socket (`/var/run/docker.sock`):** Mounts the host's Docker socket. This is vital because Gitea Actions spins up short-lived Docker containers (like an Ubuntu runner) to execute individual workflow steps.
    - **Configuration:** Inherits registration tokens and labels dynamically via the `.env` file.

### 4. Security Layer (`docker-proxy`)

- **Image:** `tecnativa/docker-socket-proxy:latest`
- **Purpose:** Acts as a firewall protecting your host machine's Docker daemon.
- **Key Mechanisms:**
    
    
    - Mounting `/var/run/docker.sock` as **Read-Only (`:ro`)** ensures containers cannot maliciously destroy the host system.
    - Environment flags (`CONTAINERS=1`, `POST=1`, etc.) precisely whitelist only the exact API calls required for your CI/CD pipelines to build images and spin up environments.

## Key Configuration Strengths for Your Docs

- **Graceful Startup Sequence:** By utilizing advanced `depends_on` rules with `condition: service_healthy`, you avoid the common race condition where Gitea tries to boot up and crashes because MySQL isn't ready to accept connections yet.
- **Environment Variables:** Keeping secrets out of the raw YAML via the `.env` file means you can safely commit this configuration file to source control without leaking passwords or SSH tokens.


networks: internal: name: internal driver: bridge external: name: external driver: bridge

services:

-------------------------------------------------------------------

1. THE REPOSITORY: Gitea Backend

-------------------------------------------------------------------

db: image: mysql:8 container_name: gitea-db restart: unless-stopped env_file: .env environment: - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} - MYSQL_DATABASE=${MYSQL_DATABASE} - MYSQL_USER=${MYSQL_USER} - MYSQL_PASSWORD=${MYSQL_PASSWORD} volumes: - ./mysql:/var/lib/mysql networks: - internal healthcheck: test: ["CMD-SHELL", "mysqladmin ping -h localhost -u${MYSQL_USER} -p${MYSQL_PASSWORD}"] interval: 10s timeout: 5s retries: 5

-------------------------------------------------------------------

2. THE REPOSITORY: Gitea Frontend

-------------------------------------------------------------------

gitea: image: gitea/gitea:latest container_name: gitea restart: unless-stopped env_file: .env environment: - USER_UID=1000 - USER_GID=1000 - GITEA__server__SSH_PORT=222 - GITEA__server__SSH_LISTEN_PORT=22 - GITEA__server__SSH_DOMAIN=mygitea.mikemcfetridge.com - GITEA_APP_NAME=${GITEA_APP_NAME} - GITEA__database__DB_TYPE=${GITEA__database__DB_TYPE} - GITEA__database__HOST=${GITEA__database__HOST} - GITEA__database__NAME=${GITEA__database__NAME} - GITEA__database__USER=${GITEA__database__USER} - GITEA__database__PASSWD=${GITEA__database__PASSWD} depends_on: db: condition: service_healthy ports: - "3000:3000" - "222:22" volumes: - ./gitea:/data - /etc/localtime:/etc/localtime:ro networks: - internal - external healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000"] interval: 10s retries: 3 timeout: 10s start_period: 30s

-------------------------------------------------------------------

3. THE GITEA runner using docker compose

-------------------------------------------------------------------

runner: image: gitea/act_runner:latest container_name: gitea-runner restart: unless-stopped depends_on: gitea: condition: service_healthy env_file: .env environment: - GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL} - GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN} - GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME} - GITEA_RUNNER_LABELS=${GITEA_RUNNER_LABELS} - GITEA_RUNNER_LOG_LEVEL=trace - GITEA_RUNNER_JOB_CONTAINER_NETWORK=external - CONFIG_FILE=/config/config.yaml volumes: - ./runner-data:/data - ./runner-config/config:/config - /var/run/docker.sock:/var/run/docker.sock networks: - internal - external

-------------------------------------------------------------------

4. THE SECURITY LAYER: Docker Socket Proxy

-------------------------------------------------------------------

docker-proxy: image: tecnativa/docker-socket-proxy:latest container_name: docker_proxy privileged: true volumes: - /var/run/docker.sock:/var/run/docker.sock:ro environment: - CONTAINERS=1 - NETWORKS=1 - SERVICES=1 - VOLUMES=1 - POST=1 # Allows Semaphore to create/start components networks: - external restart: unless-stopped


> ⚠️ **CRITICAL SECURITY NOTE:** This file contains highly sensitive plaintext credentials (database root passwords, registration tokens). **Never commit the actual `.env` file to a public or shared Git repository.** Instead, save this version locally and commit a sanitized `example.env` or `.env.example` file to your documentation repo.

## Environment Configuration (`.env`)

This file contains the environment variables injected into the Docker Compose stack. It centralizes secrets, database credentials, and cross-service communication parameters.

<div class="code-block ng-tns-c2069566202-672 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-ini%2C-toml"><div class="formatted-code-block-internal-container ng-tns-c2069566202-672"><div class="animated-opacity ng-tns-c2069566202-672"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-672 ng-star-inserted"><span class="ng-tns-c2069566202-672">Ini, TOML</span><div class="buttons ng-tns-c2069566202-672 ng-star-inserted"></div></div></div></div></div>```
# =====================================================================
# 1. DATABASE CONFIGURATION (MySQL)
# =====================================================================
MYSQL_ROOT_PASSWORD=Lzh8CMp3K0c84n
MYSQL_DATABASE=gitea
MYSQL_USER=gitea
MYSQL_PASSWORD=Lzh8CMp3K0c84n

# =====================================================================
# 2. GITEA CORE APP SETTINGS
# =====================================================================
GITEA_APP_NAME=Gitea

# Database Connection (Tells Gitea frontend how to talk to the MySQL backend)
GITEA__database__DB_TYPE=mysql
GITEA__database__HOST=db:3306
GITEA__database__NAME=gitea
GITEA__database__USER=gitea
GITEA__database__PASSWD=Lzh8CMp3K0c84n

# =====================================================================
# 3. GITEA ACT RUNNER CONFIGURATION (CI/CD)
# =====================================================================
# The full URL where the runner can reach your Gitea instance
GITEA_INSTANCE_URL=https://mygitea.mikemcfetridge.com

# Secret token generated in Gitea Web UI to authorize this runner
GITEA_RUNNER_REGISTRATION_TOKEN=FJlPWQmf9fwDw0TSxTJ37g6i1lZGdgcyDMw2aviE

# Friendly name displayed inside Gitea Site Administration -> Actions
GITEA_RUNNER_NAME=local-runner

# Mapping workflow OS names to the actual Docker images used to execute jobs
GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:16-bullseye,ubuntu-22.04:docker://node:16-bullseye,ubuntu-20.04:docker://node:16-bullseye

## Technical Explanations for Your Documentation

The Cheat Code: GITEA__database__HOST=db:3306

Notice that the database host is not an IP address; it is simply db. Because both your gitea and db services share the internal Docker network, Docker's built-in DNS handles the routing automatically using the container names.

Deciphering the Double Underscores (__)

Gitea handles environment configuration by translating double underscores into its internal structure. For example:

  • GITEA__database__DB_TYPE explicitly configures the [database] section of Gitea's underlying app.ini configuration file, setting DB_TYPE = mysql.

Runner Image Mapping (GITEA_RUNNER_LABELS)

In the homepage deployment YAML we analyzed earlier, the step declared runs-on: ubuntu-latest.

The GITEA_RUNNER_LABELS variable bridges that instruction to reality. It explicitly tells the runner: "When a workflow asks for ubuntu-latest, download the node:16-bullseye container to execute those commands inside."

💡 Recommendation for Your Setup

Your GITEA_RUNNER_LABELS is currently leveraging a generic Node image (node:16-bullseye) as the execution container. For deployment tasks involving server actions or heavy shell utilities (like your scp-action and ssh-action), you might occasionally run into missing dependency quirks. If workflows fail, a more robust base runner image option to swap to later is catthehacker/ubuntu:act-latest.

To register your runner, you will need to pull the registration token from your Gitea web interface and restart your container stack so the configuration takes effect.

Here is the exact verification and initialization process to document for your setup:

Runner Registration & Verification Workflow

Step 1: Fetch the Token from the Gitea UI

Before your runner can connect, it needs authorization.

  1. Log into your Gitea instance (https://mygitea.mikemcfetridge.com).
  2. Navigate to Site Administration $\rightarrow$ Actions $\rightarrow$ Runners.
  3. Click Create New Runner.
  4. Copy the registration token and paste it into your .env file as the value for GITEA_RUNNER_REGISTRATION_TOKEN.

Step 2: Spin Up the Stack

From the directory containing your docker-compose.yml and .env files, bring up the entire stack in the background:

Bash
``` docker compose up -d

<div class="code-block ng-tns-c2069566202-685 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk--4"><div class="formatted-code-block-internal-container ng-tns-c2069566202-685"><div class="animated-opacity ng-tns-c2069566202-685"></div></div></div>Because of your `depends_on` healthcheck configurations, the runner will patiently wait for Gitea to be fully initialized before it attempts to register itself.

### Step 3: Verify Registration via Logs

To confirm that the runner successfully authenticated with your Gitea instance, check the container logs:

<div class="code-block ng-tns-c2069566202-686 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-bash-1"><div class="formatted-code-block-internal-container ng-tns-c2069566202-686"><div class="animated-opacity ng-tns-c2069566202-686"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-686 ng-star-inserted"><span class="ng-tns-c2069566202-686">Bash</span><div class="buttons ng-tns-c2069566202-686 ng-star-inserted"></div></div></div></div></div>```
docker logs -f gitea-runner

Look for a log line that resembles the following output:
Plaintext
``` level=info msg="Registering runner, name=local-runner, instance=https://mygitea.mikemcfetridge.com" level=info msg="Runner registered successfully" level=trace msg="Starting runner daemon..."

<div class="code-block ng-tns-c2069566202-687 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk--10"><div class="formatted-code-block-internal-container ng-tns-c2069566202-687"><div class="animated-opacity ng-tns-c2069566202-687"></div></div></div>## Common Gotcha: DNS &amp; Routing Loops

Since your `GITEA_INSTANCE_URL` is configured with a fully qualified domain name (`https://mygitea.mikemcfetridge.com`), your runner container will try to resolve this address via external DNS.

If your external DNS routes traffic to a reverse proxy (like Nginx Proxy Manager), your runner needs to be able to loop back out and back into your local network seamlessly. If the runner logs show connection timeouts or "no such host" errors, you may need to add an `extra_hosts` block to your runner service in `docker-compose.yml` to force local resolution:

<div class="code-block ng-tns-c2069566202-688 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation" id="bkmrk-yaml"><div class="formatted-code-block-internal-container ng-tns-c2069566202-688"><div class="animated-opacity ng-tns-c2069566202-688"><div class="code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c2069566202-688 ng-star-inserted"><span class="ng-tns-c2069566202-688">YAML</span><div class="buttons ng-tns-c2069566202-688 ng-star-inserted"></div></div></div></div></div>```
    extra_hosts:
      - "mygitea.mikemcfetridge.com:192.168.2.15" # Points directly to your host/proxy IP

Once the runner shows a green "Idle" status in your Gitea admin panel, it is officially ready to process that homepage deployment workflow!