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
No comments yet. Be the first to share your thoughts!