The Solopreneur's Guide to Gitea + Portainer Auto-deployments

Subscribe to the Newsletter

Get the latest posts, dev tips, and startup stories straight to your inbox. No spam, ever.


The Solopreneur's Guide to Gitea + Portainer Auto-deployments
With the billionaire/one-percenter class becoming more brazenly corrupt on a daily basis, we all need to find a way to rely less on the handful of individuals that think they are above the law and society. They openly commit crimes and publicly admit they will go to jail if law-abiding humans ever take charge again. They lie, steal, cheat, divide and mass surveil us in order to control and sell our most intimate data for profit and get our tax money as a reward for such terrible and unethical behavior.

I humbly reject that world of giving nefarious, silver-spooned "tech moguls" my digital thoughts and interactions and choose to self-host mostly every digital aspect of my life. I own my data. I own my code. I own my digital life. I built my own server as a learning experience years ago and could not be happier with the outcome and digital freedom. Sure, I do spend a weekend a year trying to figure out a dumb update-gone-wrong, but it is satisfying having actual ownership and control over my own data without fear of another data breach or selling of my data.

Enough chit-chat from my pedestal yapping about personal opinions, let's get to the main goodies. As you could have guessed, I host my own git server via Gitea on my server. The high-level configuration is Cloudflare + Caddy + Gitea (or other services like Portainer). I was previously using DigitalOcean for hosting, but with the new year upon and a fresh pep in my step, I am going to be vibe coding a couple different Minimum Lovable Products (MLP) to test with the public and hope one of my ideas provides enough value that people want to pay me money to use the service. As a solopreneur, I want to keep my costs down while I try to build and grow a product/brand. No better way to save some money than to self-host my own websites and hope we run into scaling limits in the future!

I'm assuming you already have Portainer installed on your machine, so we're going to go ahead and create a new stack in Portainer for our Caddy (reverse proxy) instance:

Create Caddy stack in Portainer:


Screenshot 2026-01-11 at 22-13-29 Portainer local.png 824 KB
---
version: "3.4"
services:
  cloudflare-ddns:
    image: oznu/cloudflare-ddns:latest
    container_name: cloudflare-ddns
    restart: unless-stopped
    environment:
      - API_KEY=yourapikey
      - ZONE=yourdomain
      - PROXIED=false
      - RRTYPE=A

  caddy:
    image: ghcr.io/iarekylew00t/caddy-cloudflare:latest
    container_name: caddy
    restart: unless-stopped
    ports:
      - 80:80
      - 443:443
      - 2019:2019
    volumes:
      - /local/caddy/config:/config
      - /local/caddy/data:/data
      - /local/caddy/Caddyfile:/etc/caddy/Caddyfile
    environment:
      - CLOUDFLARE_API_TOKEN=yourapitoken
      - MY_DOMAIN=yourdomain.com
      - CADDY_BASIC_AUTH_USERNAME=yourusername
      - CADDY_BASIC_AUTH_PASSWORD=yourpassword

networks:
  default:
    external:
      name: caddy_net


Here we are using Cloudflare as a proxy to route to our Caddy instance that will then route to our self-hosted services. In order for Cloudflare to know what our forwarding IP is, we need our local machine to send our local IP to Cloudflare via the `cloudflare-ddns` service defined above. Once Cloudflare knows our local IP that is hosting the services, we can use Caddy to route the requests to our server's Docker services orchestrated via Portainer.

modern_cloudflare_caddy_portainer.png 75.3 KB


Create a Gitea stack including Act-Runner:


image.png 793 KB
---
version: "3.4"
services:
  gitea:
    image: gitea/gitea:1
    container_name: gitea
    restart: unless-stopped
    env_file:
      - stack.env # Set from Environment variables below
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - DB_TYPE=postgres
      - DB_HOST=gitea-postgres:5432
      - DB_NAME=${DB_NAME}
      - DB_USER=${DB_USER}
      - DB_PASSWD=${DB_PASSWORD}
      - DISABLE_REGISTRATION=true
      - DOMAIN=${DOMAIN}
      - SSH_DOMAIN=${SSH_DOMAIN}
      - ROOT_URL=${ROOT_URL}
      - RUN_MODE=prod
    volumes:
      - /local/data/gitea:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - - ${EXPOSE_PORT:-3000}:3000
      - "127.0.0.1:61208:22"
    depends_on:
      - gitea-postgres

  gitea-postgres:
    image: postgres:17-alpine
    container_name: gitea-db
    environment:
      - POSTGRES_DB=${DB_NAME}
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    restart: always
    volumes:
      - /local/data/postgresql/data:/var/lib/postgresql/data

  gitea-act-runner:
    image: gitea/act_runner:latest
    container_name: gitea-act-runner
    depends_on:
      - gitea
    environment:
      - GITEA_RUNNER_LABELS=ubuntu-latest
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /local/data/gitea-act-runner:/data
    restart: always

networks:
  default:
    external:
      name: caddy_net
      

Update Caddyfile to point to the new Gitea container:


~$ vi /local/caddy/Caddyfile

##
#  cloudflare services
#  remember to set docker-compose with external network caddy_net!
##

## Global settings
{
    # Global options block
    email cloudflare@email.com

    cert_issuer acme {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
        resolvers 1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4
        propagation_timeout 10m
        propagation_delay 30s
    }
}

git.yourdomain.com {
        reverse_proxy gitea:3000 # always bind port to internal port, not your custom mapped port in compose file
}

Restart the Caddy stack in Portainer to reload the Caddyfile and you should now be able to reach your Gitea stack by the provided sub.domain!

Now it's time to start setting up our auto-deployment workflow when we merge a branch to `main`. After you get your code uploaded into Gitea, go to the repo's settings and set your secrets for your script:

image.png 358 KB


Now let's add a workflow to our project:

# app/.gitea/deploy.yml

name: Docker Build and Push Latest Image

on:
  push:
    branches:
      - main

jobs:
  docker-deploy-latest:
    runs-on: ubuntu-latest
    env:
      REGISTRY: ${{ secret.REGISTRY_URL }}
      IMAGE_NAME:  ${{ secret.BUILD_IMAGE_NAME }}
      REGISTRY_IMAGE_NAME: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
      REGISTRY_REF_NAME: refs/heads/main # Reference name of a Git repository hosting the Stack file
      PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
      PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
      PORTAINER_STACK_ID: ${{ secret.PORTAINER_STACK_ID }}   # Portainer stack that shouldn't change unless delete/recreate the Portainer stack
      PORTAINER_ENDPOINT_ID: 1 # This ID is usually 1 and will not change unless you delete/recreate the Portainer environment
      PORTAINER_CONTAINER_NAME: ${{ secrets.PORTAINER_CONTAINER_NAME }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Gitea Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: |
            ${{ env.REGISTRY_IMAGE_NAME }}:latest
            ${{ env.REGISTRY_IMAGE_NAME }}:${{ gitea.sha }}

      - name: Redeploy stack in Portainer
        run: |
          check_http() {
            local response="$1" step="$2"
            local code
            code=$(echo "$response" | grep -o '"httpCode":[0-9]*' | cut -d: -f2)
            if ! [[ "$code" =~ ^2[0-9][0-9]$ ]]; then
              echo "❌ $step failed (HTTP $code)"
              echo "Response: $response"
              exit 1
            fi
            echo "✅ $step OK (HTTP $code)"
          }

          echo "🔄 Redeploying stack $STACK_ID via Portainer..."

          INFO_RESPONSE=$(curl -s -w '"httpCode":%{http_code}' \
            -H "X-API-Key: $PORTAINER_API_KEY" \
            -H "Content-Type: application/json" \
            -X GET \
            "$PORTAINER_URL/api/stacks/$PORTAINER_STACK_ID")

          check_http "$INFO_RESPONSE" "Stack info request"
      
          BODY=${INFO_RESPONSE%\"httpCode\":*}
          STACK_ENV_JSON=$(echo "$BODY" | jq '.Env')

          REDEPLOY_RESPONSE=$(curl -s -w '"httpCode":%{http_code}' \
            -H "X-API-Key: $PORTAINER_API_KEY" \
            -H "Content-Type: application/json" \
            -X PUT \
            -d "{
              \"env\": $STACK_ENV_JSON,
              \"prune\": true,
              \"PullImage\": true,
              \"RepositoryAuthentication\": true,
              \"RepositoryUsername\": \"${{ secrets.REGISTRY_USERNAME }}\",
              \"RepositoryPassword\": \"${{ secrets.REGISTRY_PASSWORD }}\",
              \"RepositoryReferenceName\": \"$REGISTRY_REF_NAME\"
            }" \
            "$PORTAINER_URL/api/stacks/$PORTAINER_STACK_ID/git/redeploy?endpointId=$PORTAINER_ENDPOINT_ID")

          check_http "$REDEPLOY_RESPONSE" "Stack redeploy request"

          echo "⏳ Waiting for container $PORTAINER_CONTAINER_NAME to be running..."

          MAX_WAIT_SECONDS="${MAX_WAIT_SECONDS:-300}"  # default once, as integer

          START_TIME=$(date +%s)

          while true; do
            INSPECT=$(curl -s \
              -H "X-API-Key: $PORTAINER_API_KEY" \
              "$PORTAINER_URL/api/endpoints/$PORTAINER_ENDPOINT_ID/docker/containers/$PORTAINER_CONTAINER_NAME/json")

            STATUS=$(echo "$INSPECT" | jq -r '.State.Status')
            HEALTH=$(echo "$INSPECT" | jq -r '.State.Health.Status // "unknown"')

            echo "Status: $STATUS, Health: $HEALTH"

            if [ "$STATUS" = "running" ] && [ "$HEALTH" != "starting" ] && [ "$HEALTH" != "unknown" ]; then
              break
            fi

            ELAPSED=$(( $(date +%s) - START_TIME ))
            if [ "$ELAPSED" -ge "$MAX_WAIT_SECONDS" ]; then
              echo "❌ Timeout waiting for container to be running"
              exit 1
            fi
            sleep 5
          done

          echo "🔍 Checking health after redeploy..."

          if [ "$HEALTH" = "unhealthy" ]; then
            echo "⚠️ Container is unhealthy after redeploy, restarting..."

            RESTART_RESP=$(curl -s -w '"httpCode":%{http_code}' \
              -H "X-API-Key: $PORTAINER_API_KEY" \
              -X POST \
              "$PORTAINER_URL/api/endpoints/$PORTAINER_ENDPOINT_ID/docker/containers/$PORTAINER_CONTAINER_NAME/restart?t=10")

            check_http "$RESTART_RESP" "Container restart due to unhealthy state"
          else
            echo "✅ Container is healthy (or no healthcheck configured)"
          fi

          echo "🎉 Redeploy + healthcheck sequence completed."
image.png 283 KB

Huzzah! This deployment script builds your project's Dockerfile and pushes it to the defined (Gitea Package Repo) URL and auto-deploys the latest image to Portainer with a status check and a restart stack request for a fallback. Now we can merge to our main branch and get back to vibe coding instead of manually pushing and deploying your latest MLP code.

Happy Vibe-coding and Auto-Deploying 👋

Comments 0

Leave a Comment

Your email is optional and will only be used to display your name.
Comments are moderated and may take time to appear.

No comments yet. Be the first to share your thoughts!