The Solopreneur's Guide to Gitea + Portainer Auto-deployments
Hosting your own server is a great way to maintain control over your most personal data. I'm going to walk through a high-level configuration using Cloudflare + Caddy + Gitea, with Portainer orchestrating everything underneath. I was previously using DigitalOcean for hosting, but with the new year upon me and a fresh pep in my step, I am going to be vibe coding a couple of different Minimum Lovable Products (MLPs) to test with the public — and I'm hoping one of my ideas provides enough value that people actually want to pay me money to use it. As a solopreneur, keeping costs down while building and growing a product is the name of the game. No better way to save money than to self-host your own infrastructure and cross your fingers that you run into scaling limits someday because that means something is actually working.
I'm assuming you already have Portainer installed on your machine, so let's go ahead and create a new stack in Portainer for our Caddy reverse proxy instance.
Create Caddy stack in Portainer:
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 traffic to our Caddy instance, which then routes to our self-hosted services. In order for Cloudflare to know what our forwarding IP is, we need our local machine to continuously report its public IP to Cloudflare via the cloudflare-ddns service defined above. Once Cloudflare has our IP, Caddy takes over and routes incoming requests to the appropriate Docker services being orchestrated by Portainer. The architecture looks something like this: Cloudflare sits at the edge, Caddy handles TLS termination and reverse proxying inside the network, and Portainer keeps all the containers humming along on the host.
Create a Gitea stack including Act-Runner:
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

The gitea-act-runner service is what gives us GitHub Actions-compatible CI/CD pipelines running entirely on our own hardware. It mounts the Docker socket so it can build and push images as part of the workflow — which is exactly what we need for the auto-deployment magic we are about to set up.
Update the 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 [email protected]

    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
}

Note that the reverse_proxy directive uses the internal container port 3000, not whatever custom port you may have mapped on the host side — a small detail that will save you some head-scratching. Once you have saved that, restart the Caddy stack in Portainer to reload the Caddyfile and you should be able to reach your Gitea instance at the subdomain you configured.
Now it is time to wire up the auto-deployment workflow that triggers whenever we merge to main. After you get your code pushed into Gitea, head to the repo's settings and add the secrets your deployment script will need.
Now let's add the workflow to the project:
# app/.gitea/deploy.yml

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."

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!