Skip to content
Projects

heyvaldemar/gitea-traefik-letsencrypt-docker-compose

View on GitHub

heyvaldemar/gitea-traefik-letsencrypt-docker-compose has 31 GitHub stars and 13 forks. GitHot measured 0 stars in 24 hours. Last checked 2026-11-15 12:30 UTC. Last push 2026-11-13.

How these counts are measured

What you can do

  • Gitea latest stable (28.3.0): repositories, issues, pull requests, actions, packages.
  • PostgreSQL backing store with healthcheck and start-order dependency.
  • Traefik v3 reverse proxy with automatic HTTP→HTTPS redirect and Let's Encrypt TLS-ALPN certificate issuance.
  • Git-over-SSH via a dedicated Traefik TCP entrypoint on port 2222: no host-level SSH conflicts.

Measured star growth

in 24h
0
in 7 days
Collecting history
in 15 days
Collecting history
in 1 month
Collecting history

Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.

About

Gitea with Let's Encrypt Using Docker Compose

Developer Tools

README.md

Original

Gitea + Traefik + Let's Encrypt on Docker Compose

Deployment Verification OpenSSF Best Practices License: MIT

Contents

This repository deploys Gitea behind Traefik with automatic Let's Encrypt TLS, backed by PostgreSQL, with git-over-SSH routed through a dedicated Traefik TCP entrypoint, a scheduled backup container (database + repositories), and companion restore scripts. One docker compose up away from a self-hosted software forge at https://your-domain.

📙 Full narrative installation guide on the blog: heyvaldemar.com/install-gitea-using-docker-compose/.

Why this stack?

Need This stack Manual install Kubernetes Other compose examples
Ready to deploy in <10 min ✅ ❌ hours of setup ✅ if K8s is already running Often
TLS via Let's Encrypt, auto-renewed ✅ Traefik ACME built-in Manual certbot Via cert-manager Rare
Git-over-SSH through the proxy ✅ Traefik TCP entrypoint Manual port juggling Service/LB config Rare
Admin auto-created on first run ✅ from env Setup wizard Varies Varies
Scheduled DB + repo backups + pruning ✅ Manual cron External Rare
Upstream images pinned by sha256 digest ✅ N/A Depends Rare
Weekly pin-freshness check in CI ✅ N/A Depends Rare
CI-verified deployment on every push ✅ healthz answers N/A Varies Rare
Credentials via env (never committed) ✅ N/A K8s Secrets Often committed plaintext

Four moving parts (Traefik + Gitea + Postgres + backups). No Kubernetes prerequisites, no manual certificate management.

Prerequisites

Before you start, you need:

  • A Linux server with a public IP. Tested on Ubuntu 22.04 LTS+ and Debian 12+. Local Mac/Windows works for dev; production is Linux.
  • Docker Engine 24+ and Docker Compose 2.20+. Quick check: docker version and docker compose version.
  • A domain you control, with two A records pointing at your server's public IP: one for Gitea (e.g. gitea.example.com), one for the Traefik dashboard (e.g. traefik.gitea.example.com). DNS must propagate before deploy or the Let's Encrypt TLS-ALPN challenge will fail.
  • Ports 80, 443, and 2222 open: 2222 carries git-over-SSH (configurable via GITEA_SHELL_SSH_PORT).
  • ~1 GB free RAM and 1 free CPU for the running stack, plus disk for repositories and backup retention.

Getting started

# 1. Clone
git clone https://github.com/heyvaldemar/gitea-traefik-letsencrypt-docker-compose
cd gitea-traefik-letsencrypt-docker-compose

# 2. Create the two Docker networks the stack expects
docker network create traefik-network
docker network create gitea-network

# 3. Copy the environment template and fill in required values
cp .env.example .env
$EDITOR .env
# ^ Required: GITEA_DB_PASSWORD, GITEA_ADMIN_PASSWORD, GITEA_ADMIN_EMAIL,
#   GITEA_HOSTNAME, GITEA_URL, TRAEFIK_HOSTNAME, TRAEFIK_ACME_EMAIL,
#   TRAEFIK_BASIC_AUTH. See .env.example for generation commands.

# 4. Deploy
docker compose -f gitea-traefik-letsencrypt-docker-compose.yml -p gitea up -d

Within a minute https://${GITEA_HOSTNAME} serves Gitea with a fresh Let's Encrypt certificate. The admin account from GITEA_ADMIN_USERNAME / GITEA_ADMIN_PASSWORD is created automatically on first run.

What success looks like

# All services healthy:
docker compose -f gitea-traefik-letsencrypt-docker-compose.yml -p gitea ps

# Gitea's health endpoint:
curl -fsS "https://${GITEA_HOSTNAME}/api/healthz"
# Expected: "status": "pass"

# Traefik issued a certificate:
docker compose -p gitea logs traefik | grep -i "adding certificate"

# Clone over SSH (after adding your key in the UI):
git clone ssh://git@${GITEA_HOSTNAME}:2222/<owner>/<repo>.git

Common first-deploy issues

  • Cert issuance fails. DNS hasn't propagated or port 80 isn't reachable from the internet. Confirm with dig +short ${GITEA_HOSTNAME} and curl -I http://${GITEA_HOSTNAME} from outside the server.
  • docker compose up fails with set in .env. A required variable is empty; the error names it.
  • network gitea-network not found. Step 2 was skipped.
  • SSH clone hangs. Port 2222 is closed on the firewall, or your remote URL uses port 22 instead of GITEA_SHELL_SSH_PORT.

Apply .env or compose-file changes

docker compose -f gitea-traefik-letsencrypt-docker-compose.yml -p gitea up -d --force-recreate

Features

  • Gitea latest stable (28.3.0): repositories, issues, pull requests, actions, packages.
  • PostgreSQL backing store with healthcheck and start-order dependency.
  • Traefik v3 reverse proxy with automatic HTTP→HTTPS redirect and Let's Encrypt TLS-ALPN certificate issuance.
  • Git-over-SSH via a dedicated Traefik TCP entrypoint on port 2222: no host-level SSH conflicts.
  • Bootstrap admin auto-created from env on first run.
  • Basic-auth protected Traefik dashboard on a separate hostname.
  • Scheduled backups of the database (pg_dump | gzip) and repository data (tar.gz) with retention pruning, plus restore scripts for both.
  • Credentials required at deploy time: compose fails fast if .env is incomplete.

Typical use cases

  • Self-hosted GitHub alternative: code on your own hardware, including private mirrors.
  • CI target for homelabs: Gitea Actions is workflow-compatible with a large part of the GitHub Actions ecosystem.
  • Internal forge for a small team: lightweight (a fraction of GitLab's footprint) with the features that matter.
  • Air-gapped or compliance-bound development: data residency without SaaS.

SSH access to repositories

Git-over-SSH flows through Traefik's TCP entrypoint on GITEA_SHELL_SSH_PORT (default 2222). Clone URLs look like:

git clone ssh://[email protected]:2222/owner/repo.git

Add your public key in Gitea (Settings → SSH / GPG Keys) first. HTTPS clones work on 443 with no extra setup.

Updating

./update.sh moves this checkout to the latest release tag — a combination this repository's CI has booted, upgraded from the previous release on the same volumes, and smoke-tested — and then runs docker compose up -d. It refuses to cross a major version unattended, refuses to run over local changes, and names any variable that became required since your version before anything has moved. ./update.sh --dry-run says what would happen. Every release cut by fleet triage also carries what upstream changed, read from its release notes against this compose file.

From 1.x to 2.0. Version 2.0 moves Gitea from 1.27 to 28. The schema migrates on the first start of the new image and the only way back is a restore, so take a backup first. Gitea 28 changes two behaviours; the changelog says what each one does and what this template does about it.

Supply chain trust

This repository is a deployment template, not a custom Docker image. It orchestrates three upstream images:

All three are pinned to tag@sha256:<digest> as interpolation defaults in the compose file's x-images block. Compose pulls by digest, not by tag, and git pull alone delivers the version combination this repository has tested. Setting an *_IMAGE_TAG variable in .env overrides the default when you deliberately want a different version.

Two override levels exist per image. <PREFIX>_IMAGE_VERSION in .env swaps only the version of that image (Compose then pulls the tag, without a digest) and leaves every other pin as tested; <PREFIX>_IMAGE_TAG replaces the whole reference, digest included. The variable names are listed in .env.example. Nested defaults need Docker Compose v2.5 or newer (2022); v2.0 to v2.4 leave the inner ${...} unexpanded and docker compose up fails with an invalid reference instead of deploying something unexpected.

The daily check-pin-freshness CI job re-resolves each pinned tag against its registry and compares the pinned Gitea and Traefik versions against the latest upstream releases. Any drift fails the run and notifies the maintainer. CI's Deployment Verification workflow runs on every push, pull request, and every day at 06:00 UTC. GitHub Actions are pinned by commit SHA; Dependabot keeps those fresh.

Verify what you deploy

Every release from v1.8.5 on carries three files made on GitHub's runner with a short-lived identity and no stored key: gitea-traefik-letsencrypt-docker-compose-<tag>.tar.gz, a git archive of exactly the tree the tag points at; gitea-traefik-letsencrypt-docker-compose-<tag>.tar.gz.sigstore.json, a keyless Sigstore signature over it; and gitea-traefik-letsencrypt-docker-compose-<tag>.intoto.jsonl, SLSA build provenance from the SLSA generator. To check them with nothing from this repository trusted:

cosign verify-blob gitea-traefik-letsencrypt-docker-compose-<tag>.tar.gz \
  --bundle gitea-traefik-letsencrypt-docker-compose-<tag>.tar.gz.sigstore.json \
  --certificate-identity-regexp '^https://github.com/heyvaldemar/gitea-traefik-letsencrypt-docker-compose/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

slsa-verifier verify-artifact gitea-traefik-letsencrypt-docker-compose-<tag>.tar.gz \
  --provenance-path gitea-traefik-letsencrypt-docker-compose-<tag>.intoto.jsonl \
  --source-uri github.com/heyvaldemar/gitea-traefik-letsencrypt-docker-compose

Add --source-tag <tag> for a release published after 24 September 2026, which is signed by the run that published it. The five releases before that date were signed by a run started by hand on main, so their provenance names the branch, not the tag; the archive is still the tag's tree, and the signature still belongs to this repository's workflow. The workflow that makes them is release-assets.yml.

Production checklist

Before exposing this to real users, check every box:

  • Strong secrets. GITEA_DB_PASSWORD and GITEA_ADMIN_PASSWORD at 24+ random characters; regenerate the Traefik dashboard BCrypt hash per deployment.
  • Disable open registration unless the forge is meant to be public: Site Administration → Authentication, or set GITEA__service__DISABLE_REGISTRATION: true on the gitea service.
  • Host-mount the backup volumes for disaster recovery: bind the backup paths to host directories covered by your off-host backup solution.
  • Verify Let's Encrypt cert issuance in the Traefik logs on first start.
  • Know the restore procedure. Run both restore scripts against a test environment before you need them in production.
  • Back up before upgrades. Gitea migrates its schema forward automatically; the way back is a restore.

Backups

The backups container performs a dump → archive → prune → sleep loop: pg_dump | gzip of the database, tar.gz of the repository data directory, pruning by retention windows, then sleeping BACKUP_INTERVAL (default 24h). All knobs are configured via .env with compose-level defaults.

Each cycle logs Database backup OK: <file> (<bytes> bytes) or Database backup FAILED (the same for the data archive where there is one). A failed dump is kept as <file>.failed for diagnosis and never overwrites a good backup. Grep the log for FAILED from your monitoring.

Verify backups are running:

docker compose -p gitea logs backups | tail -5
docker compose -p gitea exec backups sh -c 'ls -la /srv/gitea-postgres/backups/ /srv/gitea-application-data/backups/'

Restore with the interactive scripts (chmod +x *.sh once):

./gitea-restore-database.sh          # database: stops Gitea, drop/create/restore, starts
./gitea-restore-application-data.sh  # repositories and data directory

Each lists the backups and asks, or takes a file name as its argument; both read every path and credential from the running backups container, and CI runs both on every push.

Resource limits

Every service carries memory and CPU limits plus reservations as compose-level defaults: the same values CI boots the stack under. Override any of them in .env (the knobs and their defaults are listed in .env.example, e.g. TRAEFIK_MEMORY_LIMIT=512m) and the override survives every git pull. If a service is OOM-killed under real load, docker inspect <container> --format '{{.State.OOMKilled}}' says so; raise its _MEMORY_LIMIT and recreate.

Container hardening

Every service runs with security_opt: no-new-privileges:true, so a process cannot gain privileges through setuid binaries even if it escapes its initial capability set. Infrastructure containers (the reverse proxy, databases, caches, backups) run with cap_drop: [ALL] and add back only what their entrypoints need: NET_BIND_SERVICE for Traefik to bind :80/:443, CHOWN/SETUID/SETGID (and friends) for database images to own their data directory and drop to their service user. Application containers keep the default capability set on purpose: upstream images assume it, and a wrong guess there is a boot loop in production rather than a hardening win. CI boots the stack under exactly these settings on every push, so what ships is what was tested.

Testing

The Deployment Verification workflow runs on every push, pull request, and every day at 06:00 UTC:

  1. Lint: shellcheck on both restore scripts, actionlint on the workflow.
  2. Trivy scans of all three pinned images (CRITICAL/HIGH, SARIF to the Security tab).
  3. Pin freshness (daily/manual): digest drift plus release-lag checks for Gitea and Traefik.
  4. Deploy-and-test: boots the full stack with ephemeral credentials and requires /api/healthz to answer pass through Traefik plus a 200 front page. The shipped configuration must produce a working forge, not just started containers.

A green run is the authoritative proof that the template deploys end-to-end and that its backups restore.

Backup and restore, proven

tests/e2e-backup-restore.sh runs against the live stack and is what CI executes after the HTTPS smoke. The scenario that matters most is the restore roundtrip: insert a marker row, restore the earliest backup, assert the marker is gone. A backup that cannot be restored fails the build. Run it yourself against a running deployment with short intervals in .env (BACKUP_INIT_SLEEP=15s, BACKUP_INTERVAL=60s):

chmod +x tests/e2e-backup-restore.sh
./tests/e2e-backup-restore.sh

It stops the database container briefly to prove failure detection. Run it on a staging copy, not on production.

Security notes

  • Credentials are read from .env at deploy time; .env is gitignored and compose fails fast on missing required variables.
  • Pre-rotation advisory. Releases before v1.0.0 (2026-08-31) shipped a tracked .env with generated-looking passwords. Rotate GITEA_DB_PASSWORD and GITEA_ADMIN_PASSWORD if your deployment reused them.
  • The database listens only on the internal network.
  • Upstream image digests are pinned; the daily freshness job flags drift loudly.

About the maintainer

Maintained by Vladimir Mikhalev · Docker Captain · CNCF Ambassador · IBM Champion · AWS Community Builder

YouTube · Blog · LinkedIn