A detailed guide to deploying Gitea on ARM64 with Docker, SQLite, Tor access, and automated encrypted backups.

Gitea ARM64 Setup: Tor Hidden Service and Sovereign Dev Workflow

New to self-hosting AI? The Self-Hosted AI: Start Here hub walks the hardware-decision tree, inference-engine choice, and the operational gotchas that bite hardest in the first three months. Read it before or after this one, whichever fits your stage.


I finally nuked my GitHub account after the third “free tier” email threatening to suspend my private repos. The last straw? A CI job failing because GitHub Actions couldn’t spell “ARM64” correctly. So I moved everything to Gitea running on a DGX Spark, here’s exactly how I did it, including the parts that broke first.

Quick Take

  • Gitea runs perfectly on ARM64 with Docker when you force the right platform
  • SQLite as the database keeps the setup simple and fast
  • Tor access works without fighting container networking
  • Backups are encrypted and scheduled automatically

The Docker Compose That Actually Starts

gitea:
  image: gitea/gitea:1.21.4
  platform: linux/arm64
  container_name: gitea
  environment:
    - GITEA__database__DB_TYPE=sqlite3
    - GITEA__server__ROOT_URL=http://localhost:3002/
    - GITEA__server__HTTP_PORT=3000
    - GITEA__server__OFFLINE_MODE=false
  volumes:
    - /data/gitea:/data
  ports:
    - "3002:3000"
  restart: always

Run docker compose up -d and watch the container start. If it fails with standard_init_linux.go:228: exec user process caused: exec format error, you forgot the platform: linux/arm64 line. That’s the first thing I missed when copying from an x86 tutorial.

Gitea’s ARM64 image exists, but Docker defaults to amd64 unless you tell it otherwise. The error above is Docker’s polite way of saying “this binary won’t run on your CPU.” Add the platform line and try again. For reference, the official ARM64 image is tagged as gitea/gitea:linux-arm64 in some documentation, but the :latest tag now correctly resolves to the ARM64 variant when the platform is specified.

Why SQLite Beats PostgreSQL Here

sqlite3 /data/gitea/gitea.db "SELECT COUNT(*) FROM repo;"

I started with PostgreSQL because that’s what all the tutorials use. Then I noticed Gitea’s ARM64 image ships with SQLite baked in, and the performance difference on a DGX Spark is negligible. SQLite handles 50 repos without breaking a sweat, and the backup is just a single file you can encrypt and ship offsite.

The gotcha? SQLite locks the database during writes. If you run backups while Gitea is active, the backup will fail or corrupt. Schedule backups for 02:00 when no one’s pushing code. You can verify the lock status with:

lsof /data/gitea/gitea.db

If the file is locked, you’ll see gitea in the output.

Tor Access Without Container Hell

docker exec -it gitea bash -c "apt update && apt install -y tor && apt install -y dnsutils"

Tor access works, but only if you install the Tor client inside the container. The official Gitea image doesn’t include it, so you have to add it yourself. After installing, edit /data/gitea/app.ini and add:

[proxy]
PROXY_ENABLED = true
PROXY_URL = http://localhost:9050

Restart Gitea, and your repos become reachable via .onion addresses. The gotcha? The container’s clock must be accurate, or Tor connections fail silently. Add GITEA__server__OFFLINE_MODE=false to keep NTP working. You can verify the time sync with:

docker exec -it gitea date

If the time is off by more than a few seconds, Tor will refuse to connect.

Backups That Don’t Lie to You

tar --exclude='*.pack' -czf /backup/gitea-$(date +%F).tar.gz /data/gitea

I tested two backup strategies: rsync to another machine and encrypted tar archives. The tar method wins because it preserves file permissions and handles SQLite’s lock file gracefully. The exclusion of .pack files is critical, those are Git packfiles that can be regenerated.

The backup script runs daily at 02:00 via systemd timer. The encryption step uses age with a key stored in a hardware security module. If the key is missing, the backup fails loudly instead of silently. Here’s the full script I use:

#!/bin/bash
BACKUP_DIR="/backup"
DATE=$(date +%F)
AGE_KEY_FILE="/etc/age/key.txt"

# Create backup
tar --exclude='*.pack' -czf ${BACKUP_DIR}/gitea-${DATE}.tar.gz /data/gitea

# Encrypt backup
age -e -a -p -i ${AGE_KEY_FILE} ${BACKUP_DIR}/gitea-${DATE}.tar.gz > ${BACKUP_DIR}/gitea-${DATE}.tar.gz.age

# Clean up unencrypted backup
rm ${BACKUP_DIR}/gitea-${DATE}.tar.gz

The systemd timer file looks like this:

[Unit]
Description=Daily Gitea Backup

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true

[Install]
WantedBy=timers.target

Git Credentials That Don’t Fight You

[credential "http://localhost:3002"]
    helper = store

Store your credentials in ~/.git-credentials with the format https://cipherfox:<TOKEN>@localhost:3002. The gotcha? If you use Docker’s internal DNS (like in OpenHands Sandbox), replace localhost:3002 with gitea:3000. The .gitconfig trick with insteadOf saves the day:

[url "http://gitea:3000/"]
    insteadOf = http://localhost:3002/

Without this, every push from inside a container tries to hit the host’s loopback instead of the Gitea service. You can verify the configuration with:

git config --global --get-regexp url

What I Actually Use

  • Gitea: because GitHub’s ARM64 CI is a joke and I’m done waiting for fixes
  • DGX Spark: the only ARM64 server that doesn’t throttle under sustained load
  • age encryption: because tar backups need to survive cloud provider outages

What changed in the Gitea setup since this post

Gitea moved fast in late 2025 / early 2026 and the setup in this post needed a few updates worth naming.

The 1.22.x line that this post pinned to is now superseded by the 1.26.x series, which adds first-class support for the SSH-only push pattern this post uses (no need for the workaround the original post documented). If you are setting up fresh, jump straight to the latest 1.26.x; the migration from a 1.22.x setup is in-place by simply bumping the Docker image tag and letting Gitea run its migrations on first boot.

SQLite-vs-PostgreSQL: the post recommended SQLite for single-user deployments, which still holds. The threshold for switching has moved up: SQLite remains comfortable through about 50 active repos and modest concurrent push traffic. Above that, PostgreSQL pays its operational cost back in lock-contention reduction; below it the simpler backup story (just copy the SQLite file) wins.

Forgejo-the-fork is now mature enough to be a real alternative. For this stack we stayed on Gitea because the upgrade path is well-trodden and the feature parity with what we need (issues for Tier-2 backlog, PRs for sovereign-blog and sovereign-mcp, webhooks for the deploy pipeline) is complete on both. If governance considerations matter to you (Forgejo is community-governed, Gitea is corporate-stewarded), the choice changes; if pure operational fit matters, either works.

The Tor-hidden-service pattern from the post still applies unchanged. That part of Gitea has been stable across versions, since it is mostly a network-layer concern rather than a Gitea feature.

The decision-quality signal worth naming: a Gitea install is the kind of thing where every quarter you should ask “is this still the right tool” and the answer should be “yes, here is why” with two specific reasons, not silent inertia. For this stack the two reasons are: (1) self-hosted Git keeps merge histories and CI traces under our control rather than a hosted vendor’s, and (2) Gitea Issues serves as the Tier-2 cross-project backlog coordination layer that no commercial alternative offers in the same lightweight form. Both reasons survive scrutiny in 2026; the day either fails, the migration evaluation starts.

The under-reported reason to pick Gitea (or any self-hosted Git) over a hosted alternative is not philosophical, it is operational. Hosted Git provides a service-level indicator (your GitHub repo is up or down based on GitHub-as-a-company being up or down) that you have no way to debug. Self-hosted Git provides an SLI you can fix: the disk is full, restart it; the container died, restart it; the network split-brained, fix the network. The SLA is whatever you make it, but the path-to-recovery is in your own hands rather than waiting on a status page. For solo developer setups that is rarely the right tradeoff (hosted is faster to set up, easier to use); for projects where the Git history is the load-bearing audit-trail of decisions, the self-hosted path stops being an indulgence and starts being basic operational hygiene.

Stack

Gitea ARM64 Setup

Self-hosted Git on ARM64 with Docker

6
Access Tor proxy
5
Service Gitea ARM64
4
Database SQLite embedded
3
Runtime Docker ARM64
2
OS Linux ARM64
1
Hardware DGX Spark ARM64

Was this worth it? Zap the article.

Value for value, no signup. Sats go straight to the writer.