Files
ubuntu-post-install/services/gitea.sh
T
Claude d3d2a411b0 gitea: add optional Gitea Actions (CI) with a local runner
Gitea Actions is Gitea's own CI, largely GitHub-Actions-workflow-compatible
(.gitea/workflows/*.yml). Off by default; this Gitea install is otherwise
just a passive GitHub pull mirror, so the main value here is resilience —
.gitea/workflows/*.yml can still run something like a GitHub Actions build
if GitHub itself is ever unreachable.

_gitea_offer_actions_runner(), offered on fresh installs and Update reruns
(idempotent — no-ops if already set up):
  - Enables GITEA__actions__ENABLED / DEFAULT_ACTIONS_URL in the compose
    file's environment, restarts to apply
  - Generates a runner registration token via `gitea actions
    generate-runner-token`
  - Appends an act_runner service to the same docker-compose.yml, using
    the host's Docker socket to launch a fresh container per job — the
    same pattern this repo already uses for portainer/watchtower/
    uptimekuma/beszel/traccar's autoheal
  - Falls back to printing manual setup instructions if token generation
    fails, rather than losing the attempt silently

_gitea_fix_ownership()'s data/-exclusion (added when we fixed the earlier
SQLite readonly-database bug) now also skips runner-data/, so a future
reinstall/update doesn't clobber the runner's own state the same way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8
2026-08-17 21:32:35 +00:00

714 lines
32 KiB
Bash

#!/bin/bash
# services/gitea.sh — Self-hosted Gitea (lightweight Git server), with an
# optional two-way GitHub mirror sync (gitea-github-sync.sh, vendored from
# the ai-stack bundle but genuinely standalone here — this does NOT pull in
# Ollama/ComfyUI/InvokeAI/any of the rest of that stack, just the one
# Gitea container + the sync script).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash gitea.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='cancel'"; return; }
echo ""
echo " 1) Update — refresh the image only, leave config/data as-is"
echo " 2) Full reinstall — wipe and reconfigure from scratch"
echo " 3) Cancel — leave the existing install untouched"
read -r -p " Choice [3]: " _r
case "$_r" in
1) eval "$_var='update'" ;;
2) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3"
local _display_port="${_upstream##*:}"
log_info "Access $_name directly on port $_display_port (no Caddy in standalone mode)."
CADDY_SERVICE_CONFIGURED=false
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service gitea utilities "Self-hosted Git server (Gitea) — raw local clones plus optional two-way GitHub mirror sync" 3001
_gitea_sync_vendor_src() {
local _self_dir
_self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "$(cd "$_self_dir/.." && pwd)/vendor/ai-stack/gitea-github-sync.sh"
}
# Prompt for a token with retries — pasted tokens over SSH sometimes race the
# prompt (the terminal delivers the paste a beat after an already-submitted
# empty line, so the token shows up echoed on the *next* line instead of
# being read). A single empty answer used to be taken as "no token", silently
# — this gives it up to 3 tries before actually giving up, and strips
# whitespace in case the paste carried a stray leading/trailing newline.
_gitea_prompt_token() {
local _question="$1" _varname="$2"
local _max=1 _tries=0 _val=""
[ "$UNATTENDED" != true ] && _max=3
while [[ $_tries -lt $_max ]]; do
prompt_text "$_question" "" _val
_val="$(printf '%s' "$_val" | tr -d '[:space:]')"
[[ -n "$_val" ]] && break
_tries=$((_tries + 1))
[[ $_tries -lt $_max ]] && log_warning " Nothing came through — if you pasted it, try again (a paste can race the prompt over SSH)."
done
eval "$_varname='$_val'"
}
# Gitea's container always runs internally as UID 1000 (USER_UID/USER_GID in
# the compose file below are fixed, independent of whoever's running this
# installer) — the official image chowns /data to that UID on its own at
# startup. A plain ensure_docker_dir_ownership call fights that: it's a
# recursive chown of the whole service directory to $ACTUAL_USER, which on
# a box where the installer runs as root directly (ACTUAL_USER=root) resets
# the live data/ back to UID 0. If the container doesn't happen to restart
# right after (e.g. Update mode against an already-running container, which
# just no-ops), nothing ever re-fixes it, and every write to Gitea's own
# SQLite DB then fails with "attempt to write a readonly database" — the
# directory holding the DB file is owned by root, not the UID 1000 process
# trying to write it. Confirmed live. Chown everything else as normal in
# $DIR; leave data/ (and the Actions runner's own runner-data/, same reason)
# for their respective containers to manage.
_gitea_fix_ownership() {
local _dir="$1"
[ "$DRY_RUN" = true ] && return 0
chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir" 2>/dev/null || true
local _entry
for _entry in "$_dir"/*; do
[ -e "$_entry" ] || continue
case "$(basename "$_entry")" in
data|runner-data) continue ;;
esac
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$_entry" 2>/dev/null || true
done
}
# Offers to add "Sign in with Authelia" (OpenID Connect) to Gitea's own
# login page. This is a different thing from Caddy forward_auth, which this
# service deliberately skips (see the Caddy call below) since Gitea already
# has a solid built-in login — this adds Authelia as an *additional* OAuth2
# login option on the same page, on top of that built-in login, not a
# replacement for it. Nothing about local admin login changes.
#
# Fully automated on both sides, matching how the rest of this installer
# avoids manual web wizards: registers Gitea as an OIDC client in Authelia
# (services/authelia.sh's _authelia_provision_oidc_client), then adds the
# resulting client as an authentication source in Gitea via its own CLI. If
# either side isn't available (Authelia not installed, or the Gitea CLI
# call fails for some reason — e.g. an older image without `admin auth
# add-oauth`), falls back to printing the values for a two-minute manual
# add in Gitea's Site Administration UI instead of losing the setup.
_gitea_offer_authelia_sso() {
local DIR="$1"
[ -d "$DOCKER_DIR/authelia" ] || return 0
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
echo ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Gitea's login page? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
local _default_domain=""
[ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ] && _default_domain="git.${SITE_DOMAIN}"
local GITEA_OIDC_DOMAIN=""
prompt_text " Domain Gitea is reachable at [${_default_domain:-required}]:" "$_default_domain" GITEA_OIDC_DOMAIN
if [ -z "$GITEA_OIDC_DOMAIN" ]; then
log_warning "No domain entered — skipping Authelia SSO for Gitea."
return 0
fi
local _2fa="" AUTH_POLICY="two_factor"
prompt_yn " Require two-factor for Gitea logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
if ! _authelia_provision_oidc_client "Gitea" "gitea" "$AUTH_POLICY" "y" \
"https://${GITEA_OIDC_DOMAIN}/user/oauth2/authelia/callback"; then
log_warning "Couldn't register Gitea as an OIDC client in Authelia — skipping SSO setup."
return 0
fi
local _discovery_url="https://auth.${OIDC_AUTHELIA_DOMAIN}/.well-known/openid-configuration"
log_info "Adding Authelia as an authentication source in Gitea..."
if docker exec -u git gitea gitea admin auth add-oauth \
--name authelia --provider openidConnect \
--key gitea --secret "$OIDC_CLIENT_SECRET_PLAIN" \
--auto-discover-url "$_discovery_url" &>/dev/null; then
log_success "\"Sign in with Authelia\" added to Gitea's login page — local admin login still works too."
else
log_warning "Couldn't add the auth source automatically. Add it by hand:"
log_warning " Gitea -> Site Administration -> Authentication Sources -> Add Authentication Source"
log_warning " Type: OAuth2, Provider: OpenID Connect, Name: authelia"
log_warning " Client ID: gitea"
log_warning " Client Secret: $OIDC_CLIENT_SECRET_PLAIN"
log_warning " Discovery URL: $_discovery_url"
log_warning " (The Client Secret above is shown once — it isn't stored in plaintext anywhere.)"
fi
}
# Offers to enable Gitea Actions (Gitea's own CI, largely GitHub-Actions-
# workflow-compatible) with a local runner — mainly useful as a fallback so
# .gitea/workflows/*.yml can still run something like a GitHub Actions build
# if GitHub itself is ever unreachable, since this Gitea is otherwise just a
# passive pull mirror. Off by default; opt-in on fresh installs and Update
# reruns alike (idempotent — a rerun after it's already set up just no-ops).
#
# The runner (gitea/act_runner) polls Gitea for jobs and needs the host's
# Docker socket to launch a fresh container per job — same pattern this repo
# already uses for portainer/watchtower/uptimekuma/beszel/traccar's autoheal,
# not something new to this file. Worth knowing: that's root-equivalent
# access to this host, standard for any CI runner, not unique to Gitea's.
_gitea_offer_actions_runner() {
local DIR="$1"
grep -q '^ act_runner:$' "$DIR/docker-compose.yml" 2>/dev/null && return 0
echo ""
local USE_ACTIONS=""
prompt_yn " Enable Gitea Actions (CI) with a local runner — runs .gitea/workflows/*.yml the same way GitHub Actions runs .github/workflows/*.yml, useful as a fallback if GitHub is ever unreachable? (y/n):" "n" USE_ACTIONS
[[ "$USE_ACTIONS" =~ ^[Yy]$ ]] || return 0
if ! grep -q 'GITEA__actions__ENABLED' "$DIR/docker-compose.yml"; then
log_info "Enabling Gitea Actions..."
sed -i '/GITEA__security__INSTALL_LOCK=true/a\ - GITEA__actions__ENABLED=true\n - GITEA__actions__DEFAULT_ACTIONS_URL=github' "$DIR/docker-compose.yml"
(cd "$DIR" && docker compose up -d) \
&& log_success "Actions enabled — Gitea restarted to apply." \
|| { log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; }
fi
log_info "Generating a runner registration token..."
local RUNNER_TOKEN=""
RUNNER_TOKEN="$(docker exec -u git gitea gitea actions generate-runner-token 2>/dev/null | tail -1)"
if [[ -z "$RUNNER_TOKEN" ]]; then
log_warning "Couldn't generate a runner token automatically (older Gitea image?). Generate one by hand:"
log_warning " Gitea -> Site Administration -> Actions -> Runners -> Create new Runner"
log_warning " then add an act_runner container yourself using that token — see"
log_warning " https://docs.gitea.com/usage/actions/quickstart for the compose snippet."
return 1
fi
mkdir -p "$DIR/runner-data"
cat >> "$DIR/docker-compose.yml" << EOF
act_runner:
image: gitea/act_runner:latest
container_name: gitea-runner
restart: unless-stopped
environment:
- GITEA_INSTANCE_URL=http://gitea:3000
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
- GITEA_RUNNER_NAME=gitea-runner
volumes:
- ./runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- gitea
EOF
_gitea_fix_ownership "$DIR"
(cd "$DIR" && docker compose up -d act_runner) \
&& log_success "Actions runner started — .gitea/workflows/*.yml will now run automatically on push." \
|| log_warning "Runner failed to start — check: docker compose -f $DIR/docker-compose.yml logs act_runner"
}
# ── Own systemd timer, not gitea-github-sync.sh's built-in --install-timer ──
# The vendor script's own timer installer always runs the script bare (no
# --pull-only/--push-only), i.e. always both directions — there's no way to
# hand it a fixed sync direction. Since this installer asks up front which
# direction to run automatically, the timer's ExecStart bakes that flag in
# directly instead of delegating to the vendor script's own (less flexible)
# --install-timer/--remove-timer modes.
_gitea_write_sync_timer() {
local DIR="$1" RUN_USER="$2" RUN_HOME="$3" FLAG="$4" INTERVAL="$5"
local _service="/etc/systemd/system/gitea-github-sync.service"
local _timer="/etc/systemd/system/gitea-github-sync.timer"
cat > "$_service" << UNIT
[Unit]
Description=Gitea-GitHub Mirror Sync
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=oneshot
User=${RUN_USER}
Environment=HOME=${RUN_HOME}
Environment=SYNC_ENV=${DIR}/.env
ExecStart=/bin/bash ${DIR}/gitea-github-sync.sh ${FLAG}
UNIT
cat > "$_timer" << UNIT
[Unit]
Description=Gitea-GitHub Sync Timer
[Timer]
OnBootSec=5min
OnUnitActiveSec=${INTERVAL}
Persistent=true
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now gitea-github-sync.timer
}
_gitea_remove_sync_timer() {
systemctl disable --now gitea-github-sync.timer 2>/dev/null || true
rm -f /etc/systemd/system/gitea-github-sync.service /etc/systemd/system/gitea-github-sync.timer
systemctl daemon-reload 2>/dev/null || true
}
# Ask sync direction + autosync, apply to either a fresh setup or a
# reconfigure of an existing one. Always asked (matches pstn-trunk.sh's
# international-calling step reasoning: a live-editable extra, not a
# structural setting tied exclusively to fresh installs).
_gitea_run_sync_direction_step() {
local DIR="$1"
echo ""
echo " Sync direction:"
echo " 1) GitHub -> Gitea only (cloud to local — backup your GitHub repos here)"
echo " 2) Gitea -> GitHub only (local to cloud — push repos created here up to GitHub)"
echo " 3) Both directions"
local _DIR_CHOICE=""
prompt_text " Choice [1]:" "1" _DIR_CHOICE
local FLAG="" DIR_DESC=""
case "$_DIR_CHOICE" in
2) FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
3) FLAG=""; DIR_DESC="both directions" ;;
*) FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
esac
log_info "Sync direction: $DIR_DESC"
_gitea_remove_sync_timer
echo ""
local AUTOSYNC=""
prompt_yn "Enable automatic sync on a schedule? (y/n):" "y" AUTOSYNC
if [[ "$AUTOSYNC" =~ ^[Yy]$ ]]; then
local INTERVAL=""
prompt_text " Sync interval (e.g. 1h, 6h, 1d) [6h]:" "6h" INTERVAL
_gitea_write_sync_timer "$DIR" "$ACTUAL_USER" "$ACTUAL_HOME" "$FLAG" "$INTERVAL"
log_success "Timer installed: syncs every $INTERVAL ($DIR_DESC)."
log_info "Check status: systemctl status gitea-github-sync.timer"
log_info "Run now: sudo systemctl start gitea-github-sync.service"
log_info "Logs: ~/.config/gitea-github-sync/sync.log"
else
log_info "Automatic sync not enabled. Run it yourself whenever you want:"
log_info " cd $DIR && bash gitea-github-sync.sh $FLAG"
[[ -z "$FLAG" ]] && log_info " (no flag needed for both directions)"
fi
# ── Run it now, off the timer — lets you confirm tokens/config are
# actually correct right here instead of waiting for the first
# scheduled run (or a manual invocation later) to find out.
echo ""
echo " Run a sync now?"
echo " 1) Dry-run preview only (--list) — shows what would sync, no changes"
echo " 2) Run for real now ($DIR_DESC)"
echo " 3) Skip — don't run anything now"
local _RUN_DEFAULT="1"
[ "$UNATTENDED" = true ] && _RUN_DEFAULT="3"
local _RUN_NOW=""
prompt_text " Choice [$_RUN_DEFAULT]:" "$_RUN_DEFAULT" _RUN_NOW
case "$_RUN_NOW" in
2)
log_info "Running sync now ($DIR_DESC)..."
sudo -u "$ACTUAL_USER" env HOME="$ACTUAL_HOME" SYNC_ENV="$DIR/.env" \
bash "$DIR/gitea-github-sync.sh" $FLAG \
&& log_success "Sync run complete." \
|| log_warning "Sync run failed — check the output above, or ~/.config/gitea-github-sync/sync.log"
;;
3) log_info "Skipped — run it later with the commands above." ;;
*)
log_info "Dry-run preview (--list)..."
sudo -u "$ACTUAL_USER" env HOME="$ACTUAL_HOME" SYNC_ENV="$DIR/.env" \
bash "$DIR/gitea-github-sync.sh" --list
;;
esac
}
install_gitea() {
log_info "Setting up self-hosted Gitea..."
local DIR="$DOCKER_DIR/gitea"
local SYNC_SRC
SYNC_SRC="$(_gitea_sync_vendor_src)"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml (gitea/gitea:latest)"
echo "[DRY-RUN] Would scan for free host ports (web + SSH) to avoid collisions"
echo "[DRY-RUN] Would prompt for a Gitea admin username/password, then create that account"
echo "[DRY-RUN] and an API token once the container is ready (no manual web wizard)"
echo "[DRY-RUN] Would prompt for a GitHub token and copy in gitea-github-sync.sh"
echo "[DRY-RUN] Would ask sync direction (GitHub->Gitea / Gitea->GitHub / both) and whether"
echo "[DRY-RUN] to install a systemd timer for automatic sync, or print manual instructions"
echo "[DRY-RUN] Would offer to run a sync now (dry-run preview or for real), off-schedule"
echo "[DRY-RUN] Would offer \"Sign in with Authelia\" (OIDC) if Authelia is installed"
echo "[DRY-RUN] Would offer to enable Gitea Actions (CI) with a local act_runner container"
echo "[DRY-RUN] Would write $DIR/README.md"
return 0
fi
if [[ ! -f "$SYNC_SRC" ]]; then
log_error "Vendored gitea-github-sync.sh not found at $SYNC_SRC"
return 1
fi
require_docker || return 1
# ── Existing install? ───────────────────────────────────────────────────
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
echo ""
log_info "Existing Gitea install found at $DIR."
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh"
chmod +x "$DIR/gitea-github-sync.sh"
_gitea_fix_ownership "$DIR"
(cd "$DIR" && docker compose up -d) \
&& log_success "Gitea refreshed and restarted." \
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
_gitea_run_sync_direction_step "$DIR"
_gitea_offer_authelia_sso "$DIR"
_gitea_offer_actions_runner "$DIR"
log_success "Existing .env (tokens) and web/SSH ports were left untouched."
return 0
;;
cancel)
log_info "Leaving the existing install as-is."
return 0
;;
fresh) log_info "Reconfiguring from scratch — every prompt below runs again." ;;
esac
fi
mkdir -p "$DIR"
_gitea_fix_ownership "$DIR"
cd "$DIR" || return 1
# ── Port scan — web (default 3001->3000) and SSH (default 2222->22) ────
local WEB_PORT=3001 SSH_PORT=2222
find_free_port WEB_PORT "$WEB_PORT"
find_free_port SSH_PORT "$SSH_PORT"
[[ "$WEB_PORT" != 3001 ]] && log_info "Port 3001 was taken — Gitea's web UI will use ${WEB_PORT}."
[[ "$SSH_PORT" != 2222 ]] && log_info "Port 2222 was taken — Gitea's SSH clone port will use ${SSH_PORT}."
cat > docker-compose.yml << EOF
name: gitea
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
ports:
- "${WEB_PORT}:3000"
- "${SSH_PORT}:22"
volumes:
- ./data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__database__PATH=/data/gitea/gitea.db
- GITEA__security__INSTALL_LOCK=true
EOF
_gitea_fix_ownership "$DIR"
docker compose up -d \
&& log_success "Gitea container started." \
|| { log_error "docker compose up failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; }
# ── Admin credentials — asked up front so a slow first boot doesn't need
# a second manual pass; these get used the moment Gitea's CLI is ready.
echo ""
local GITEA_ADMIN_USER=""
prompt_text " Gitea admin username [$ACTUAL_USER]:" "$ACTUAL_USER" GITEA_ADMIN_USER
local _GEN_PASS GITEA_ADMIN_PASS=""
_GEN_PASS="$(generate_password 24)"
prompt_text " Gitea admin password [$_GEN_PASS]:" "$_GEN_PASS" GITEA_ADMIN_PASS
# ── Wait for Gitea to actually be ready, then create the account. One
# retry loop instead of a separate readiness probe: first boot (SQLite
# init) can take well over a minute on slower disks, and folding account
# creation into the same loop means a slow-but-eventually-successful boot
# doesn't dead-end the install the way a fixed 60s probe used to.
log_info "Waiting for Gitea to finish starting (first boot can take a minute or two)..."
local _tries=0 _created=false _exists=false
while [[ $_tries -lt 60 ]]; do
if docker exec -u git gitea gitea admin user create --admin \
--username "$GITEA_ADMIN_USER" --password "$GITEA_ADMIN_PASS" \
--email "${GITEA_ADMIN_USER}@localhost" --must-change-password=false \
&>/dev/null; then
_created=true
break
fi
# Gitea is up but this username already exists (e.g. retry after an
# earlier partial run) — treat as success and sync the password to
# what was just entered rather than failing the whole install.
if docker exec -u git gitea gitea admin user list 2>/dev/null | awk '{print $2}' | grep -qx "$GITEA_ADMIN_USER"; then
_exists=true
# --must-change-password=false matters here: change-password
# defaults to setting that flag TRUE, which then makes Gitea
# reject every API call (including this script's own token-based
# calls) with 403 "You must change your password" until someone
# logs into the web UI and clears it by hand. Confirmed live —
# this silently broke the sync script on every retry against an
# already-existing account.
docker exec -u git gitea gitea admin user change-password \
--username "$GITEA_ADMIN_USER" --password "$GITEA_ADMIN_PASS" \
--must-change-password=false &>/dev/null
_created=true
break
fi
sleep 2
_tries=$((_tries + 1))
done
if [[ "$_created" != true ]]; then
log_error "Gitea didn't come up in time — check: docker compose -f $DIR/docker-compose.yml logs"
log_error "Once it's healthy, just re-run 'sudo ./setup.sh gitea' to pick up from here."
return 1
fi
if [[ "$_exists" == true ]]; then
log_success "Admin account already existed: $GITEA_ADMIN_USER (password updated to what you just entered)"
else
log_success "Admin account created: $GITEA_ADMIN_USER"
fi
# Token name includes a timestamp so a retry against an account that
# already has a "sync" token from an earlier partial run (see the
# already-exists branch above) never collides — Gitea rejects a second
# token with a name that's already taken for that user, which used to
# silently fall through to the manual-paste prompt below on every retry.
local GITEA_TOKEN=""
GITEA_TOKEN="$(docker exec -u git gitea gitea admin user generate-access-token \
--username "$GITEA_ADMIN_USER" --token-name "sync-$(date +%s)" \
--scopes write:repository,write:user --raw 2>/dev/null)"
if [[ -z "$GITEA_TOKEN" ]]; then
log_warning "Automatic token generation didn't work (older Gitea image?) — generate one"
log_warning "by hand: log into http://localhost:${WEB_PORT} as $GITEA_ADMIN_USER, then"
log_warning "Settings -> Applications -> Generate New Token (repo + user write access)."
_gitea_prompt_token " Paste the GITEA token here (not the GitHub one — that's next):" GITEA_TOKEN
fi
# ── GitHub token ─────────────────────────────────────────────────────────
echo ""
log_info "Needs a GitHub Personal Access Token (not an SSH key — this talks to GitHub's"
log_info "REST API too, which SSH can't do). Generate one at https://github.com/settings/tokens"
log_info "with 'repo' scope if you don't already have one handy."
local GITHUB_TOKEN=""
_gitea_prompt_token " GitHub token:" GITHUB_TOKEN
if [[ -z "$GITHUB_TOKEN" ]]; then
log_warning "No GitHub token entered — Gitea itself is still up, but the sync script won't"
log_warning "work until you add one to $DIR/.env and re-run this installer (update mode)."
fi
cp -f "$SYNC_SRC" "$DIR/gitea-github-sync.sh"
chmod +x "$DIR/gitea-github-sync.sh"
cat > "$DIR/.env" << ENV
# Written by services/gitea.sh — re-run that (update mode) to change any of this.
GITEA_URL='http://localhost:${WEB_PORT}'
GITEA_TOKEN='${GITEA_TOKEN}'
GITHUB_TOKEN='${GITHUB_TOKEN}'
ENV
chmod 600 "$DIR/.env"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" "$DIR/gitea-github-sync.sh"
# ── Discover GitHub/Gitea usernames + scope prefs (the sync script's own
# first-time setup) — runs as the real user, not root, so its config
# lands under the real user's home, not /root.
if [[ -n "$GITHUB_TOKEN" && -n "$GITEA_TOKEN" ]]; then
echo ""
if [ "$UNATTENDED" = true ]; then
log_info "Unattended mode — skipping the interactive sync setup. Run it yourself later:"
log_info " cd $DIR && sudo -u $ACTUAL_USER bash gitea-github-sync.sh --init"
else
sudo -u "$ACTUAL_USER" env HOME="$ACTUAL_HOME" SYNC_ENV="$DIR/.env" \
bash "$DIR/gitea-github-sync.sh" --init
fi
fi
_gitea_run_sync_direction_step "$DIR"
# ── Caddy — no forward_auth gate here. Gitea has its own built-in login,
# unlike the no-auth-at-all apps elsewhere in this repo that need Caddy
# to gate them via Authelia. Optional "Sign in with Authelia" (OIDC) is
# offered separately below, as an addition to Gitea's own login, not a
# replacement requiring Caddy involvement. ─────────────────────────────
configure_caddy_for_service "Gitea" "host.docker.internal:${WEB_PORT}" "git"
_gitea_offer_authelia_sso "$DIR"
_gitea_offer_actions_runner "$DIR"
write_readme "$DIR" << MD
# Gitea
Self-hosted Git server. Raw, real working-copy clones are just normal
\`git clone\` commands against it (or against GitHub directly) — Gitea's own
storage is separate from that, used for the web UI and the mirror sync
below.
- Web UI: http://localhost:${WEB_PORT}
- Admin login: \`${GITEA_ADMIN_USER}\` / see \`.env\` if you need the generated
password again (\`docker exec -u git gitea gitea admin user change-password\`
to rotate it)
- SSH clone port: ${SSH_PORT} (e.g. \`git clone ssh://git@localhost:${SSH_PORT}/user/repo.git\`)
## GitHub mirror sync
\`gitea-github-sync.sh\` (in this directory) mirrors repos between this Gitea
and GitHub. Tokens live in \`.env\` (chmod 600) — treat them like passwords.
\`\`\`bash
cd $DIR
bash gitea-github-sync.sh --list # preview what would sync, no changes
bash gitea-github-sync.sh --pull-only # GitHub -> Gitea only
bash gitea-github-sync.sh --push-only # Gitea -> GitHub only
bash gitea-github-sync.sh # both directions
\`\`\`
Config (which repos, private/forks handling) lives at
\`~/.config/gitea-github-sync/config\` — edit directly, or re-run
\`bash gitea-github-sync.sh --init\` to redo it interactively.
## Sign in with Authelia (optional)
If Authelia is installed, re-run \`sudo ./setup.sh gitea\` (Update mode is
fine — this doesn't touch tokens or anything else) and answer yes to
"Add \"Sign in with Authelia\"?" to add it as an extra OAuth2 login option
on Gitea's own login page. Local admin login keeps working exactly as
before — this is additive, not a replacement. Managed in Gitea under
Site Administration -> Authentication Sources (source name: \`authelia\`).
## Gitea Actions (CI) — optional local runner
Re-run \`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to
"Enable Gitea Actions?" to run \`.gitea/workflows/*.yml\` here the same way
GitHub Actions runs \`.github/workflows/*.yml\` — mainly useful as a fallback
so builds still work if GitHub is ever unreachable. Adds an \`act_runner\`
container (\`docker compose ps\` will show \`gitea-runner\`) that polls this
Gitea instance for jobs and launches a fresh container per job using this
host's own Docker socket — same pattern already used by this repo's
portainer/watchtower/uptimekuma services, not something new. Manage runners
under Site Administration -> Actions -> Runners.
## Manage
\`\`\`bash
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose up -d
sudo ./setup.sh gitea # re-run to change sync direction/schedule, or refresh
\`\`\`
MD
echo ""
log_success "Gitea installed at $DIR"
echo " Web UI: http://localhost:${WEB_PORT} (login: ${GITEA_ADMIN_USER})"
echo " Details, sync commands: $DIR/README.md"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_gitea