Full production-readiness pass: fixed every issue found during exhaustive functional and restart-stability testing (admin/user/repo/fork/mirror+sync, webhook delivery, real Gitea Actions CI job runs, paced and rapid-burst container restarts), then brought all touched scripts into full lint compliance. Most significant: Gitea Actions CI jobs failed 100% of the time due to a nested-overlayfs Docker-in-Docker mount conflict, despite runners appearing registered and online — only an actual job run surfaced it. Now verified end-to-end across two full passes: a real workflow reaches `status: success`, the full restart-stability suite (3x paced + 4x rapid-burst restarts) shows identical runner UUIDs with no duplicate registrations, and a final focused smoke test confirmed the lint-only edits (exit-code and trap-line changes included) introduced zero regressions. - rootfs/tmp/etc/docker/daemon.json: added `storage-driver: fuse-overlayfs` to the baked default config. The inner (DinD) dockerd's default overlayfs driver conflicted with the outer container's own overlay-backed root filesystem, causing every job container creation to fail with `failed to mount ... fstype: overlay ... err: invalid argument`. fuse-overlayfs (already bundled in the image) mounts entirely in userspace via FUSE, avoiding the kernel-level conflict; no new run-flag requirement since `--privileged`/`SYS_ADMIN` already covers it. - rootfs/usr/local/etc/docker/init.d/05-dockerd.sh: same `storage-driver: fuse-overlayfs` fix added to both daemon.json generation branches (with/without registry) as defense-in-depth for the case where the baked `/etc/docker` seed directory is absent; also clears a stale `/tmp/docker.pid` before each start attempt so dockerd doesn't refuse to start after a restart when the PID namespace reset lets an unrelated early-boot process reuse the old PID number; fixed a call to the non-existent `symlink` function (missing its `__` prefix) that silently no-op'd the `/var/lib/docker` symlink setup on every run — corrected to `__symlink`, the actual function defined in functions/entrypoint.sh and used consistently everywhere else in this codebase; also updated its `su_cmd touch "$SERVICE_PID_FILE"` call site to `__su_cmd` to match the function rename in functions/entrypoint.sh (see that entry below). Lint pass: added `VERSION="..."` assignment and refreshed the `##@Version` header timestamp; added `--` before 8 grep search patterns; wrapped 2 unconditional emoji echoes in the existing NO_COLOR conditional pattern. - rootfs/usr/local/etc/docker/init.d/08-gitea.sh: updated the one `su_cmd touch "$SERVICE_PID_FILE"` call site to `__su_cmd`, matching the function rename in functions/entrypoint.sh (see that entry above) — otherwise this script would call an undefined function on every service start. Since this required touching the file anyway, brought it into the same lint compliance as the other 5 scripts in this pass: added `VERSION="..."` assignment and refreshed the `##@Version` header timestamp (this script previously had neither); added `--` before 8 grep search patterns. - rootfs/usr/local/etc/docker/init.d/zz-act_runner.sh: removed the legacy single "gitea"-named runner registration and daemon-start blocks, which duplicated the runner set already owned by start-runners/RUNNERS_START; removed the second unconditional `__post_execute` invocation at the bottom of the script that duplicated runner registration on every start; switched RUNNER_IP_ADDRESS to loopback instead of the detected external IP4_ADDRESS, which was transient/wrong under Docker-in-Docker networking and caused "no route to host" registration failures; redirected the cache-server and start-runners background job output to real log files (instead of inheriting the __post_execute pipe) and disowned both jobs, since a long-running background process inheriting that pipe's write end would never let the reading `tee` see EOF, hanging __run_start_script forever; also updated its `su_cmd touch "$SERVICE_PID_FILE"` call site to `__su_cmd` to match the function rename in functions/entrypoint.sh (see that entry below). Lint pass: refreshed the stale `##@Version`/`VERSION=` timestamp since the file was substantially edited this pass; broke one 207-char line into multiple lines without changing behavior; confirmed the 2 existing emoji echoes are already NO_COLOR-guarded. - rootfs/usr/local/bin/start-runners: refactored the default RUNNER_LABELS value into an array joined with a comma, avoiding one very long hardcoded line. Lint pass: added the missing standard script header block and a `VERSION="..."` assignment (this script previously had neither); wrapped the fatal-error trap's emoji echo in a NO_COLOR conditional, matching the existing pattern used elsewhere in the repo. - rootfs/usr/local/bin/entrypoint.sh: the stale-PID-file restart guard now also checks the live process's own cmdline for "entrypoint.sh" before treating the recorded PID as still running, since the PID namespace resets on every `docker restart` while `/run` persists, letting an unrelated early-boot process coincidentally reuse the old PID number and cause the guard to wrongly skip __start_init_scripts on a real restart. Lint pass: added `VERSION="..."` assignment and refreshed the `##@Version` header timestamp; added `--` before 6 grep search patterns (including one missed on the first fix pass); replaced 2 bare `exit` statements with explicit exit codes. - rootfs/usr/local/etc/docker/functions/entrypoint.sh: same PID-reuse false-positive fix applied to `__no_exit`'s monitor-loop guard, via a `__no_exit_monitor_loop` marker embedded in the exec'd process's own cmdline so a future restart can tell a genuine still-running monitor loop apart from an unrelated process that reused its PID; also renamed the 6 `su_cmd()` helper definitions inside `__switch_to_user()` to `__su_cmd()` to follow this codebase's `__`-prefix naming convention for internal functions, and updated all 3 call sites (05-dockerd.sh, 08-gitea.sh, zz-act_runner.sh — all `su_cmd touch "$SERVICE_PID_FILE"`) so the shared function rename doesn't silently break any of the three init.d scripts that source it. Lint pass: added `VERSION="..."` assignment and refreshed the `##@Version` header timestamp; added `--` before 15 grep search patterns; wrapped 4 unconditional emoji echoes in the existing NO_COLOR conditional pattern; broke one 200-char line into multiple lines without changing behavior. - rootfs/tmp/etc/gitea/app.ini: moved `ALLOWED_HOST_LIST` from the deprecated `[webhook]` section to `[security]`, removing a `[E] Deprecation:` warning logged on every gitea startup and admin-CLI invocation (`[webhook].ALLOWED_HOST_LIST` is deprecated and slated for removal in Gitea v28.0.0). - TODO.AI.md: documented every bug found and fixed this pass, including full verification details (exact error messages, root-cause tracing, live confirmation steps, and regression-suite results) for each.
gitea
A self-hosted Docker image for Gitea — a lightweight, fast Git hosting service — bundled with the Gitea Actions runner (act_runner) and Docker-in-Docker support so CI pipelines run out of the box.
🐳 Docker
Quick start
docker run -d \
--name casjaysdevdocker-gitea-latest \
--privileged \
--restart always \
--tty \
--cgroupns private \
--hostname git.example.com \
--domainname example.com \
--network bridge \
--cap-add CHOWN \
--cap-add SYS_TIME \
--cap-add SYS_ADMIN \
-e TZ=America/New_York \
-e HOSTNAME=git.example.com \
-e GITEA_PROTO=http \
-e DATABASE_DIR_SQLITE=/data/db/sqlite \
-v /srv/docker/gitea/data:/data:z \
-v /srv/docker/gitea/config:/config:z \
-v /srv/docker/databases/sqlite/gitea:/data/db/sqlite:z \
-p 80:80 \
-p 22:22 \
casjaysdevdocker/gitea:latest
via docker compose
# nginx proxy address - http://172.17.0.1:80
x-logging: &default-logging
driver: json-file
options:
max-size: "5m"
max-file: "1"
services:
gitea:
image: casjaysdevdocker/gitea:latest
pull_policy: always
container_name: casjaysdevdocker-gitea-latest
hostname: git.example.com
domainname: example.com
privileged: true
tty: true
restart: always
logging: *default-logging
cgroupns_mode: private
cap_add:
- CHOWN
- SYS_TIME
- SYS_ADMIN
environment:
TZ: ${TZ:-America/New_York}
CONTAINER_NAME: casjaysdevdocker-gitea-latest
HOSTNAME: ${BASE_HOST_NAME:-git.example.com}
GITEA_PROTO: http
DATABASE_DIR_SQLITE: /data/db/sqlite
volumes:
- ./volumes/data:/data:z
- ./volumes/config:/config:z
- ./volumes/db/sqlite:/data/db/sqlite:z
ports:
- "172.17.0.1:80:80"
- "172.17.0.1:22:22"
networks:
- gitea
networks:
gitea:
name: gitea
external: false
Environment variables
General
| Variable | Default | Description |
|---|---|---|
TZ |
America/New_York |
Timezone |
DEBUGGER |
(empty) | Set to on to enable shell-level debug tracing |
Server / domain
| Variable | Default | Description |
|---|---|---|
GITEA_SERVER |
hostname -f |
Public FQDN — sets ROOT_URL, DOMAIN, SSH_DOMAIN, and all email addresses. Always set this in production. |
GITEA_HOSTNAME |
(empty) | Alias for GITEA_SERVER |
FULL_DOMAIN_NAME |
(empty) | Fallback FQDN used when neither GITEA_SERVER nor GITEA_HOSTNAME is set |
DOMAIN |
(empty) | Overrides the domain used in email addresses (takes precedence over GITEA_SERVER) |
GITEA_PROTO |
http |
Protocol used in ROOT_URL (http or https) |
GITEA_PORT |
80 |
Internal port Gitea listens on |
GITEA_NAME |
SelfHosted GIT Server |
Site title shown in the UI |
GITEA_TZ |
$TZ |
Override timezone for Gitea specifically |
GITEA_WORK_DIR |
/data/gitea |
Override Gitea's work path |
Users
| Variable | Default | Description |
|---|---|---|
GITEA_ROOT_USER_NAME |
(empty) | Initial admin account username (created on first run) |
GITEA_ROOT_PASS_WORD |
(empty) | Initial admin account password |
GITEA_USER_NAME |
(empty) | Initial normal user username |
GITEA_USER_PASS_WORD |
(empty) | Initial normal user password |
| Variable | Default | Description |
|---|---|---|
GITEA_ADMIN |
administrator@<GITEA_SERVER> |
Admin contact / mailer FROM address |
GITEA_EMAIL_RELAY |
172.17.0.1 |
SMTP relay host |
GITEA_EMAIL_CONFIRM |
false |
Set to yes to require email confirmation and enable the mailer |
Database
| Variable | Default | Description |
|---|---|---|
GITEA_SQL_TYPE |
sqlite3 |
Database type (sqlite3, mysql, postgres) |
GITEA_SQL_HOST |
localhost |
Database host (external DB only) |
GITEA_SQL_DB_HOST |
$GITEA_SQL_HOST |
Alternate database host variable |
GITEA_SQL_USER |
(empty) | Database user (external DB only) |
GITEA_SQL_PASS |
(empty) | Database password (external DB only) |
GITEA_SQL_NAME |
(empty) | Database name (external DB only) |
DATABASE_DIR_SQLITE |
$DATA_DIR/db/sqlite |
Override the SQLite database directory (mount a separate volume here to keep the DB outside /data) |
act_runner
| Variable | Default | Description |
|---|---|---|
RUNNERS_START |
5 |
Number of act_runner instances to register |
RUNNER_CACHE_PORT |
44015 |
Port for the act_runner cache server |
RUNNER_IP_ADDRESS |
container IP | IP address act_runner registers with Gitea |
RUNNER_DEFAULT_HOME |
/config/act_runner/gitea |
Directory where runner registration state is stored |
RUNNER_CONFIG_NAME |
act_runner.yaml |
Runner config filename inside RUNNER_DEFAULT_HOME |
ACT_RUNNER_FALLBACK_VERSION |
v1.0.8 |
Pinned act_runner version used if gitea.com is unreachable during build |
Runner labels are set automatically based on the host architecture. All jobs run inside Docker containers — no bare-metal execution.
| Host arch | Labels registered |
|---|---|
x86_64 |
amd64:docker://ubuntu:latest, linux:docker://ubuntu:latest, linux/amd64:docker://ubuntu:latest, + language images |
aarch64 |
arm64:docker://ubuntu:latest, linux:docker://ubuntu:latest, linux/arm64:docker://ubuntu:latest, + language images |
Language image labels available on both architectures: node (14/16/18/20/22/latest), perl, ruby, python/python3, php/php7/php8, alpine, debian, ubuntu, almalinux/rhel/redhat, ubuntu-latest.
Volumes
| Path | Purpose |
|---|---|
/data |
Repositories, SQLite database, LFS objects, attachments, indexes |
/config |
app.ini, SSH host keys, act_runner config — persisted across container restarts |
Ports
| Port | Protocol | Purpose |
|---|---|---|
80 |
TCP | Gitea web UI and API |
22 (internal) / 7833 (default external) |
TCP | Git over SSH — host port 22 is typically taken by sshd; map container port 22 to an available host port and set SSH_PORT to match |
Notes
--privilegedis required for Docker-in-Docker (act_runner runs CI jobs inside containers).- The container ships its own
/etc/resolv.conf(Cloudflare + Google DNS, no search domain) so DNS resolution inside the container is not affected by the host's search domain configuration. GITEA_SERVERmust be set for a production deployment — without it,ROOT_URL, SSH clone URLs, and all system email addresses fall back to the container's short hostname.- The mailer is disabled by default. Set
GITEA_EMAIL_CONFIRM=yesto enable it along with the SMTP relay. - SQLite is the default database. For external MySQL/Postgres set
GITEA_SQL_TYPE,GITEA_SQL_HOST,GITEA_SQL_USER,GITEA_SQL_PASS, andGITEA_SQL_NAME.
🏃 Adding external runners
External runners let you add dedicated hardware (e.g. a native ARM64 server) to your Gitea Actions pool without running the full container. Each runner registers directly against your Gitea instance and declares its own labels, so matrix workflows can target it by architecture.
1 — Get a registration token
In the Gitea web UI: Site Administration → Runners → Create Runner Token
Or via API:
curl -s -X POST https://git.example.com/api/v1/user/actions/runners/registration-token \
-H "Authorization: token <your-api-token>"
2 — Install the act_runner binary
# Detect arch
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
VER=v1.0.8
curl -LSsf "https://gitea.com/gitea/runner/releases/download/${VER}/gitea-runner-${VER#v}-linux-${ARCH}" \
-o /usr/local/bin/act_runner
chmod +x /usr/local/bin/act_runner
3 — Register against your Gitea instance
act_runner register \
--instance https://git.example.com \
--token <registration-token> \
--name "arm64-server" \
--labels "arm64:docker://ubuntu:latest,linux/arm64:docker://ubuntu:latest,alpine:docker://alpine:latest,debian:docker://debian:latest" \
--no-interactive
Label format: name:type:image — all jobs run inside Docker containers, never directly on the host.
arm64:docker://ubuntu:latest— dispatched to this runner, job runs in a native arm64 Ubuntu containerlinux/arm64:docker://ubuntu:latest— OCI-style label for the same runner- Docker must be installed and running on the host machine
4 — Run as a systemd service
# /etc/systemd/system/act_runner.service
[Unit]
Description=Gitea Actions Runner
After=network.target
[Service]
ExecStart=/usr/local/bin/act_runner daemon
WorkingDirectory=/var/lib/act_runner
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
mkdir -p /var/lib/act_runner
mv .runner /var/lib/act_runner/ # move registration file to working dir
systemctl daemon-reload
systemctl enable --now act_runner
Matrix workflow example
Once both an amd64 and an arm64 runner are registered:
jobs:
build:
strategy:
matrix:
arch: [amd64, arm64]
runs-on: ${{ matrix.arch }}
steps:
- uses: actions/checkout@v4
- run: uname -m # confirms native arch
🛠️ Development
Prerequisites
- Docker with
buildx bash,git
Build from source
git clone https://github.com/casjaysdevdocker/gitea "$HOME/Projects/github/casjaysdevdocker/gitea"
cd "$HOME/Projects/github/casjaysdevdocker/gitea"
buildx
Install via dockermgr
sudo bash -c "$(curl -q -LSsf https://github.com/systemmgr/installer/raw/main/install.sh)"
sudo systemmgr --config && sudo systemmgr install scripts
dockermgr update gitea
📄 License
MIT — see LICENSE.md