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:
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:
---
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_netHere 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.
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
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:
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."
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!