Deployment
Gitea Mirror is a single service with a SQLite database. It listens on port 4321, keeps everything it needs in one data directory, and needs no external database, cache, or queue. Every deployment method below comes down to the same three things: run the app, give it a persistent data directory, and set two secrets.
Docker with the prebuilt image is the fastest way in.
What every deployment needs
| Requirement | Detail |
|---|---|
| Port | 4321 by default, override with PORT. |
| Data directory | Holds the SQLite database, generated secrets, and repository snapshots. Must persist across restarts. |
BETTER_AUTH_SECRET |
Signs session tokens. Use at least 32 characters. |
ENCRYPTION_SECRET |
Encrypts GitHub, Gitea, and notification tokens at rest. |
| Health endpoint | GET /api/health, used by container and Kubernetes probes. |
Both secrets are generated automatically on first start if you do not provide them, and are written into the data directory as .better_auth_secret and .encryption_secret.
Warning: Losing the data directory loses those generated secrets. Session cookies stop working, and every stored GitHub and Gitea token becomes undecryptable, so you would have to re-enter them. Back the directory up, or set both secrets explicitly so they live somewhere you control.
Docker
The published image is ghcr.io/raylabshq/gitea-mirror:latest, built for linux/amd64 and linux/arm64. It declares a volume at /app/data, exposes port 4321, and runs as a non-root user.
Prebuilt image with compose
This is the quick start path, matching docker-compose.alt.yml in the repository. It only sets what cannot be configured through the web UI.
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
container_name: gitea-mirror
restart: unless-stopped
ports:
- "4321:4321"
user: "1000:1000"
volumes:
- ./data:/app/data
environment:
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BETTER_AUTH_URL=http://localhost:4321
- PUBLIC_BETTER_AUTH_URL=http://localhost:4321
- BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:4321
Create a .env file next to it:
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
Then start it:
docker compose up -d
Open http://localhost:4321, create the first account, and configure GitHub and Gitea credentials through the UI.
The user line controls ownership of the bind-mounted ./data directory. Set it to your own UID and GID, which id -u and id -g will tell you, if you hit permission errors on the mount.
Running from the repository
If you clone the repository, both compose files are ready to use:
git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror
# Quick start, prebuilt image, data in ./data
docker compose -f docker-compose.alt.yml up -d
# Full setup, builds from source, named volume, every env var wired up
docker compose up -d
docker-compose.yml is the one to use when you want to configure everything through environment variables rather than the UI. It covers GitHub and Gitea credentials, mirror options, scheduling, cleanup behaviour, header authentication, and custom CA certificates, and it can build the image locally instead of pulling it.
Note: Environment variables pre-fill the configuration on first start. You can still change any of it later in the web UI, and your changes are stored in the database.
Plain docker run
docker run -d \
--name gitea-mirror \
--restart unless-stopped \
-p 4321:4321 \
-v gitea-mirror-data:/app/data \
-e BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \
-e BETTER_AUTH_URL=https://mirror.example.com \
-e PUBLIC_BETTER_AUTH_URL=https://mirror.example.com \
ghcr.io/raylabshq/gitea-mirror:latest
Upgrading
docker compose pull
docker compose up -d
Database migrations run automatically at startup. Keep the data volume attached and nothing else is required.
Kubernetes with Helm
A Helm chart lives in the repository at helm/gitea-mirror. It is not published to a chart repository, so install it from a clone.
The chart deploys a Deployment, a Service, a ConfigMap, a Secret, an optional PVC, and optionally an Ingress or Gateway API HTTPRoutes. It needs Kubernetes 1.23 or newer and Helm 3.8 or newer.
Installing
git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror
kubectl create namespace gitea-mirror
helm upgrade --install gitea-mirror ./helm/gitea-mirror \
--namespace gitea-mirror \
--values my-values.yaml
A minimal my-values.yaml:
image:
tag: "3.24.0"
gitea-mirror:
core:
betterAuthSecret: "your-32-character-minimum-secret"
encryptionSecret: "your-encryption-secret"
betterAuthUrl: "https://mirror.example.com"
betterAuthTrustedOrigins: "https://mirror.example.com"
github:
username: "your-github-username"
token: "ghp_..."
gitea:
url: "https://gitea.example.com"
token: "your-gitea-token"
persistence:
enabled: true
size: 5Gi
storageClass: "longhorn"
ingress:
enabled: true
className: "nginx"
hosts:
- host: mirror.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: mirror-tls
hosts:
- mirror.example.com
Note: The chart’s
appVersiontrails the application, so leavingimage.tagempty pins an older image than you probably want. Setimage.tagexplicitly to the release you intend to run.
Values that matter
| Value | Default | Why it matters |
|---|---|---|
image.tag |
"" |
Falls back to the chart appVersion. Set it explicitly. |
gitea-mirror.core.betterAuthSecret |
"" |
Session signing secret. |
gitea-mirror.core.encryptionSecret |
"" |
Token encryption secret, stored in the chart’s Secret. |
gitea-mirror.core.betterAuthUrl |
http://localhost:4321 |
Must be the external URL users reach. |
gitea-mirror.core.betterAuthTrustedOrigins |
http://localhost:4321 |
Comma-separated allowed origins. |
gitea-mirror.existingSecret |
"" |
Name of a Secret you manage yourself. When set, the chart creates no Secret of its own. |
persistence.enabled |
true |
Turning this off gives you an emptyDir, and the database vanishes on restart. |
persistence.size |
1Gi |
Raise it if you enable repository snapshots. |
deployment.strategy.type |
Recreate |
Keep this. SQLite on a ReadWriteOnce volume cannot support two running pods. |
service.port |
4321 |
Service and container port. |
ingress.* / route.* |
disabled | Ingress or Gateway API HTTPRoutes, pick one. |
Warning: Do not scale the Deployment past one replica. The database is a single SQLite file on a
ReadWriteOncevolume, and a second pod cannot mount or safely share it.
Bringing your own Secret
Set gitea-mirror.existingSecret to the name of a Secret containing GITHUB_TOKEN, GITEA_TOKEN, and ENCRYPTION_SECRET. The chart then skips creating its own and references yours, which keeps credentials out of your values file and out of version control.
gitea-mirror:
existingSecret: "gitea-mirror-secrets"
Probes and storage
The chart mounts a volume at /app/data and points the liveness, readiness, and startup probes at /api/health. All three default to a 60 second initial delay, which is generous enough for first-run database initialization.
Nix and NixOS
The repository is a flake. Nothing needs configuring for a first run: secrets are generated, the database is initialized, and the server starts.
Running it
# Straight from GitHub, no clone
nix run github:RayLabsHQ/gitea-mirror
# Pin to a release
nix run github:RayLabsHQ/gitea-mirror/vX.Y.Z
# Install into your profile
nix profile install github:RayLabsHQ/gitea-mirror
gitea-mirror
If you have not enabled flakes globally, add --extra-experimental-features 'nix-command flakes' to each command, or enable them permanently in ~/.config/nix/nix.conf:
experimental-features = nix-command flakes
Run this way, data lands in ~/.local/share/gitea-mirror unless you override DATA_DIR.
NixOS module
The flake exposes a NixOS module that runs Gitea Mirror as a hardened systemd service:
{
inputs.gitea-mirror.url = "github:RayLabsHQ/gitea-mirror";
services.gitea-mirror = {
enable = true;
betterAuthUrl = "https://mirror.example.com";
betterAuthTrustedOrigins = "https://mirror.example.com";
openFirewall = true;
};
}
Available options:
| Option | Default | Description |
|---|---|---|
enable |
false |
Turn the service on. |
package |
flake default | Override the package. |
dataDir |
/var/lib/gitea-mirror |
Data and database location. |
user / group |
gitea-mirror |
Service account. |
host |
0.0.0.0 |
Bind address. |
port |
4321 |
Listen port. |
betterAuthUrl |
http://localhost:4321 |
External URL of the service. |
betterAuthTrustedOrigins |
http://localhost:4321 |
Comma-separated trusted origins. |
mirrorIssueConcurrency |
3 |
Concurrent issue mirror operations. |
mirrorPullRequestConcurrency |
5 |
Concurrent pull request mirror operations. |
environmentFile |
null |
File holding BETTER_AUTH_SECRET and ENCRYPTION_SECRET if you want to set them yourself. |
openFirewall |
false |
Open the configured port in the firewall. |
Without an environmentFile, both secrets are generated on first start and stored in dataDir.
Database helpers ship alongside the main binary:
gitea-mirror-db init
gitea-mirror-db check
gitea-mirror-db fix
LXC on Proxmox
The Proxmox VE Community Scripts project maintains an installer that creates an LXC container and sets everything up:
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"
Run it from a Proxmox VE shell. See the Proxmox VE Community Scripts page for options and update instructions.
Note: This script is maintained by the Community Scripts project rather than by Gitea Mirror, so its behaviour and its update cadence are theirs.
Bare metal with Bun
Gitea Mirror runs on the Bun runtime, version 1.2.9 or newer. There is no Node.js build.
# Install Bun
curl -fsSL https://bun.sh/install | bash
# Get the source
git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror
# Install dependencies and initialize the database
bun run setup
# Build the production bundle
bun run build
# Run it
BETTER_AUTH_SECRET="$(openssl rand -base64 32)" bun run start
The server listens on http://localhost:4321. Data goes to data/gitea-mirror.db relative to the working directory, following DATABASE_URL.
For development with hot reload, use bun run dev instead of building.
Running under systemd
[Unit]
Description=Gitea Mirror
After=network.target
[Service]
Type=simple
User=gitea-mirror
WorkingDirectory=/opt/gitea-mirror
EnvironmentFile=/etc/gitea-mirror/env
ExecStart=/usr/local/bin/bun run start
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
With /etc/gitea-mirror/env holding:
NODE_ENV=production
HOST=0.0.0.0
PORT=4321
DATABASE_URL=file:data/gitea-mirror.db
BETTER_AUTH_SECRET=your-32-character-minimum-secret
ENCRYPTION_SECRET=your-encryption-secret
BETTER_AUTH_URL=https://mirror.example.com
PUBLIC_BETTER_AUTH_URL=https://mirror.example.com
Data and persistence
Everything that must survive a restart lives in one directory, /app/data in Docker and dataDir elsewhere:
| Path | Contents |
|---|---|
gitea-mirror.db |
SQLite database: configuration, repositories, jobs, activity, users, sessions. |
.better_auth_secret |
Generated session secret, if you did not supply one. |
.encryption_secret |
Generated encryption secret, if you did not supply one. |
repo-backups/ |
Git bundle snapshots, when force-push protection is set to create them. |
Back up the whole directory. Snapshots in repo-backups/ are the part that grows, so size the volume with your retention settings in mind.
Reverse proxy
Behind a proxy, tell the app its public URL or session cookies and CSRF checks will not line up:
BETTER_AUTH_URL=https://mirror.example.com
PUBLIC_BETTER_AUTH_URL=https://mirror.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://mirror.example.com
Serving from a subpath additionally needs BASE_URL, while the auth URLs stay origin-only:
BASE_URL=/mirror
BETTER_AUTH_URL=https://git.example.com
PUBLIC_BETTER_AUTH_URL=https://git.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
Forward X-Forwarded-Host and X-Forwarded-Proto from your proxy. The app reads them to detect its own origin, which covers many setups even before you set the variables above.
Verifying a deployment
curl http://localhost:4321/api/health
A healthy instance reports its status along with the running version, whether an update is available, database connectivity, and the state of the crash recovery system:
{
"status": "ok",
"timestamp": "2026-08-03T14:22:31.004Z",
"version": "3.24.0",
"latestVersion": "3.24.0",
"updateAvailable": false,
"database": { "connected": true },
"recovery": { "status": "healthy", "jobsNeedingRecovery": 0 }
}
status is error when the database is unreachable, and degraded when jobs are waiting on recovery after an unclean shutdown. This is the same endpoint the Docker health check and the Kubernetes probes use, so a container stuck unhealthy usually means the app failed to start rather than that the check is wrong. Check the logs first.
