Migrating a single user gitea instance for secure use over tailscale

The Primordial era of git usage

I'm not sure when I first used git as a tool directly. I compiled my first linux distro in 2003 so it probably was around then when I first was exposed and had to use it as an end user. Public git repositories like github and gitlab drove the adoption of proper git standards when confronted with the fact that competent people would be reviewing my embarrassing commits and they would be forever read by the world.

github era

Even pre-microsoft acquisition I preferred the workflow elements over gitlab. I started keeping projects on github but it requires a bit more overhead to make sure that secrets and confidential information doesn't end up in commit histories and branches of public repositories. My first repo was https://github.com/russ-go/mg-rdfa, forked in Oct 2013 but my first real was on https://github.com/russ-go/GoogleSnippets in 2016.

In the ancient year of [year] a much younger, but apparently as wizened, Russell decided on a set of core services that needed self hosting and uptime availability; a unused SBC+usbHD ws provisioned and services.lan was born into the world:

 ─────────────────────────────────────────────────────
 DietPi v10.5.2 : 13:48 - Sat 2026-07-25
 ─────────────────────────────────────────────────────
 - Device model : RPi 3 Model B+ (aarch64)
 - CPU temp : 46 °C / 114 °F : Optimal temperature
 - LAN IP : [redacted] (eth0)
 - MOTD : Debian 13 Trixie has been released. Learn how to upgrade:
          https://dietpi.com/blog/?p=4014
 ─────────────────────────────────────────────────────

Among those services was a gitea instance that was to sit safely inside the firewall, on the secure local lan. It was a local repository for my projects (where lower security risk code could stay private). Where project files could be synced across local machines on the lan.

The end is near

The gitea instance survived the gitea vs forgejo war but the writing was on the wall when my Actions based blog deployment failed out when Actions couldn't be enabled with on the dated version I was using.

The Plan

I knew the move had three phases: prep on the Oracle side while the Pi was still serving, the actual migration which would take the Pi down for maybe ten minutes, and post-migration which meant upgrading from 1.19.3 to whatever was current.

The Pi was running Gitea 1.19.3 as a native DietPi systemd service with MySQL — not SQLite, which my first plan draft assumed. The Oracle instance had Docker, Traefik, and plenty of disk. The piece I had to think through was security: I wanted Gitea accessible over Tailscale only, but I also wanted git SSH to work through the normal port so I didn't have to type ssh -T git@git.mosverde.ca -p 2222 every time I pushed.

The approach was:

  1. Prep on Oracle — Create the docker-compose.yml, the Traefik Docker labels and middleware, and line up the Gitea version. The compose file runs MariaDB 10.11 and Gitea, with the container SSH port mapped to host port 22 and the web UI on 127.0.0.1:3000 behind Traefik.

  2. The dump — On the Pi, systemctl stop gitea, then gitea dump which produces a single ZIP with the MySQL dump, all 27 repos, avatars, config — everything. Restart Gitea immediately to keep downtime short. The dump was 682 MB.

  3. The restore — Transfer the dump to Oracle, unzip it, start MariaDB, pipe in the SQL, copy the repo and data directories, chown properly, bring up Gitea. Then update the DO A record for git.mosverde.ca to point to Oracle's Tailscale IP.

  4. The upgrade — Bump the Docker tag from 1.19.3 to latest and restart. Gitea handles schema migrations automatically. It transitioned through something like 300 schema versions without complaint.

New Pipeline

The Oracle setup runs in Docker, which means the whole application is two containers defined in one compose file:

services:
  db:
    image: mariadb:10.11
    container_name: gitea-db
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=[redacted]
      - MYSQL_DATABASE=gitea
      - MYSQL_USER=gitea
      - MYSQL_PASSWORD=[redacted]
    volumes:
      - /opt/gitea/mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect"]
      interval: 10s
      timeout: 5s
      retries: 5

  server:
    image: docker.gitea.com/gitea:latest
    container_name: gitea
    depends_on:
      db:
        condition: service_healthy
    restart: always
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=mysql
      - GITEA__database__HOST=db:3306
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=[redacted]
      - GITEA__server__DOMAIN=[git-host]
      - GITEA__server__ROOT_URL=https://[git-host]/
      - GITEA__server__HTTP_PORT=3000
      - GITEA__server__SSH_DOMAIN=[git-host]
      - GITEA__server__SSH_PORT=22
      - GITEA__server__SSH_LISTEN_PORT=22
      - GITEA__server__LFS_START_SERVER=true
      - GITEA__server__DISABLE_SSH=false
      - GITEA__server__OFFLINE_MODE=false
      - GITEA__security__INSTALL_LOCK=true
      - GITEA__actions__ENABLED=true
    volumes:
      - /opt/gitea/data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:3000:3000"
      - "0.0.0.0:22:22"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.gitea.rule=Host(`[git-host]`)"
      - "traefik.http.routers.gitea.entrypoints=web-secure"
      - "traefik.http.routers.gitea.tls=true"
      - "traefik.http.routers.gitea.tls.certResolver=myresolver"
      - "traefik.http.services.gitea.loadbalancer.server.port=3000"
      - "traefik.http.routers.gitea.middlewares=tailscale-only@file"

The Traefik Docker labels are the same pattern every other service on this instance uses — Host() rule, entrypoint, TLS via Let's Encrypt DNS challenge, load balancer port. The middleware reference tailscale-only@file pulls from a file provider config that whitelists [tailscale ip range].

The HTTPS path is straightforward:

Internet ──port 443──→ OCI firewall (open) ──→ Traefik (TLS) ──→ middleware (tailscale-only check)
    ↓
403 if not Tailscale   ←─ [Tailscale IP range] ─→ Gitea container :3000

The SSH path is simpler:

Tailscale user ──port 22──→ OCI firewall (restricted to [Tailscale IP range]) ──→ Gitea container :22

The webhook for the blog deployment pipeline came along for the ride — it's the same digital-portfolio-bloglocalhost:8082 webhook that triggers the deploy script. The Gitea Actions runner isn't set up yet, but Actions is confirmed enabled in config. That's a phase 2 item.

The DNS pointer is [git-host][Tailscale IP] (Oracle's Tailscale IP). All the other subdomains that still point to the SBC3 Tailscale IP (code, home, services, vault, etc.) were untouched.

Security

Security on this one is three layers, and I actually like how it came together.

Layer 1: OCI firewall. The security list on the Oracle VCN restricts ports 22 and 2222 to [Tailscale IP range]. Nothing outside the Tailscale mesh can even try to SSH into the Gitea container or the host. Port 443 is still public because Traefik needs to terminate TLS, and port 80 is needed for ACME HTTP-01. But if you're not on the tailnet, port 443 responds with a 403 from the Traefik middleware before the request ever reaches Gitea.

Layer 2: Traefik middleware. The tailscale-only middleware is an IP whitelist for [Tailscale IP range]. It's in a separate conf.d/ file loaded by Traefik's file provider, which keeps it decoupled from the Docker label routing. The middleware fires at the router level, so a non-Tailscale HTTPS request goes through TLS termination (which has to happen for the middleware to inspect the host header and route to the right service), then hits the IP check, and returns a 403. The cert still gets served, which is fine — there's no sensitive information in the TLS handshake itself.

Layer 3: SSHD port shuffle. This was the finicky part. Ubuntu 24.04 uses socket-activated SSH, not a standalone sshd. I wanted Gitea's container to have port 22 for transparent SSH access, so host SSH needed to move. I created a systemd drop-in for ssh.socket to add port 2222 and disabled the port 22 listener. This broke both ports on the first attempt — the socket reload failed silently, I lost SSH entirely, and spent about 30 minutes recovering via OCI boot volume rescue before I realized what happened. The fix was straightforward once I understood the socket activation model: a single systemctl edit ssh.socket with the right port config.

The final SSH layout:

Access User Host Port Allowed From
Admin SSH ubuntu [oracle-host] 2222 [Tailscale IP range]
Git SSH git [git-host] 22 [Tailscale IP range]

Realistically, for a single-user instance, this is probably overkill. If someone compromises the Oracle instance, all three layers become irrelevant because they're running on the same box. But it means the only way to even try hitting Gitea is through the Tailscale network, and the only Tailscale nodes in this tailnet are my machines. No open ports to the internet beyond what Traefik needs to serve certs, no password auth exposed, no web UI login page reachable from a random VPS scanner.

The one thing I'd change if I did it again: test the SSH socket override on a throwaway instance first instead of experimenting on production. The 30-minute recovery window was entirely avoidable and the serial console on OCI ARM is a dead end without a root password set at provisioning time.

The End

The sacrifice of local git repo high availability was acceptable to me in in this connected world. Where I go, my tailscale goes. The specs of the oracle host are far above anything I could field locally at the moment and I think it's a great fit for the VPS features our household requries.

I'll just assume my 91.37 uptime garauntee will be subsumed by whatever oracle can deliver.

Slán go fóill russ-go

Comments