Move canonical docs to the website (#354)

* docs: move canonical documentation to the website

The website now hosts the full documentation at /docs with a proper docs
layout: sidebar navigation, on-page table of contents, mobile nav, theme
support, canonical and OG meta, and overflow-safe code blocks and tables.
Ten pages, all rewritten from the current code rather than copied from
the old in-app docs: quickstart, deployment (Docker, Helm, Nix, LXC,
bare metal), configuration, environment variable reference,
notifications (all four providers including webhook payload signing),
authentication (including header auth), force-push protection,
architecture, advanced, and custom CA certificates.

This fixes every inaccuracy found in the docs audit: the wrong
raylabs/gitea-mirror image name, JWT_SECRET presented as the live auth
secret instead of BETTER_AUTH_SECRET, the missing auth env vars, both
wrong DATABASE_URL defaults, the contradicting starred-org default, and
health endpoint fields the API deliberately does not return.

The in-app /docs pages are retired: a stub redirects old bookmarks to
the website, and the sidebar and 404 links point there directly. The
markdown files under docs/ stay as the versioned offline reference;
NOTIFICATIONS.md now covers Gotify and Webhook. README links the docs
site, mentions notifications, and drops stale version markers. The app
viewport meta gains initial-scale=1.

* docs: mark new-repo notification as unimplemented, bump helm appVersion to 3.24.0
This commit is contained in:
ARUNAVO RAY authored and GitHub committed 2026-08-03 11:56:51 +05:30
1 parent 63a4c9359a
commit 5c49191d61
27 files changed
+2840 -2931

No files matched your search

+2 -1
View File
@@ -18,7 +18,8 @@ export function Header() {
{ href: '/#features', label: 'Features' },
{ href: '/#use-cases', label: 'Use Cases' },
{ href: '/#screenshots', label: 'Screenshots' },
{ href: '/#installation', label: 'Installation' }
{ href: '/#installation', label: 'Installation' },
{ href: '/docs/', label: 'Docs' }
];
return (
+281
View File
@@ -0,0 +1,281 @@
---
import '../styles/global.css';
import { Header } from '../components/Header';
import Footer from '../components/Footer.astro';
import { docsNav } from '../lib/docs-nav';
const { frontmatter, headings = [] } = Astro.props;
const title = frontmatter?.title ?? 'Documentation';
const description =
frontmatter?.description ??
'Documentation for Gitea Mirror, the self-hosted GitHub to Gitea mirroring service.';
const currentPath = Astro.url.pathname.endsWith('/')
? Astro.url.pathname
: `${Astro.url.pathname}/`;
const canonical = `https://gitea-mirror.raylabs.io${currentPath}`;
const toc = headings.filter((h: { depth: number }) => h.depth === 2 || h.depth === 3);
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} · Gitea Mirror Docs</title>
<meta name="description" content={description} />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={`${title} · Gitea Mirror Docs`} />
<meta property="og:description" content={description} />
<meta property="og:type" content="article" />
<meta property="og:url" content={canonical} />
<meta property="og:image" content="https://gitea-mirror.raylabs.io/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<script is:inline>
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', theme === 'dark');
</script>
</head>
<body class="min-h-screen bg-background text-foreground antialiased">
<Header client:load />
<div class="mx-auto max-w-7xl px-4 pt-24 pb-20 sm:px-6 lg:px-8">
<div class="flex gap-10">
<!-- Sidebar navigation -->
<aside class="hidden w-56 shrink-0 lg:block">
<nav class="sticky top-24 space-y-6" aria-label="Docs">
<a
href="/docs/"
class="block text-sm font-semibold text-foreground hover:text-indigo-500"
>
Documentation
</a>
{docsNav.map((section) => (
<div class="space-y-2">
<div class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
{section.title}
</div>
<ul class="space-y-1">
{section.items.map((item) => (
<li>
<a
href={item.href}
class:list={[
'block rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
currentPath === item.href
? 'bg-indigo-500/10 font-medium text-indigo-500'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
]}
>
{item.title}
</a>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
<!-- Content -->
<main class="min-w-0 flex-1">
<!-- Mobile section picker -->
<details class="mb-6 rounded-lg border border-border lg:hidden">
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium">
Docs navigation
</summary>
<div class="space-y-4 border-t border-border p-4">
{docsNav.map((section) => (
<div class="space-y-1.5">
<div class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
{section.title}
</div>
<ul class="space-y-1">
{section.items.map((item) => (
<li>
<a href={item.href} class="block py-1 text-sm text-muted-foreground">
{item.title}
</a>
</li>
))}
</ul>
</div>
))}
</div>
</details>
<article class="docs-content">
<slot />
</article>
</main>
<!-- Table of contents -->
{toc.length > 1 && (
<aside class="hidden w-52 shrink-0 xl:block">
<nav class="sticky top-24 space-y-2" aria-label="On this page">
<div class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
On this page
</div>
<ul class="space-y-1 border-l border-border">
{toc.map((h: { depth: number; slug: string; text: string }) => (
<li>
<a
href={`#${h.slug}`}
class:list={[
'block border-l-2 border-transparent py-0.5 text-[13px] text-muted-foreground transition-colors hover:border-indigo-500 hover:text-foreground',
h.depth === 2 ? 'pl-3' : 'pl-6',
]}
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
</aside>
)}
</div>
</div>
<Footer />
</body>
</html>
<style is:global>
.docs-content {
font-size: 1rem;
line-height: 1.75;
color: var(--muted-foreground);
}
.docs-content > * + * {
margin-top: 1.1rem;
}
.docs-content :is(h1, h2, h3, h4) {
font-weight: 700;
color: var(--foreground);
letter-spacing: -0.01em;
scroll-margin-top: 6rem;
}
.docs-content h1 {
font-size: clamp(1.9rem, 2.5vw + 1rem, 2.5rem);
line-height: 1.15;
}
.docs-content h2 {
font-size: 1.5rem;
margin-top: 2.75rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.docs-content h3 {
font-size: 1.15rem;
margin-top: 2rem;
}
.docs-content a {
color: var(--foreground);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
}
.docs-content a:hover {
text-decoration-color: currentColor;
}
.docs-content strong {
color: var(--foreground);
}
.docs-content :is(ul, ol) {
padding-left: 1.4rem;
}
.docs-content ul {
list-style: disc;
}
.docs-content ol {
list-style: decimal;
}
.docs-content li + li {
margin-top: 0.35rem;
}
.docs-content code {
font-size: 0.85em;
background: var(--muted);
color: var(--foreground);
border-radius: 0.3rem;
padding: 0.15rem 0.4rem;
}
.docs-content pre {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 0.6rem;
padding: 1rem 1.25rem;
font-size: 0.85rem;
line-height: 1.6;
}
.docs-content pre code {
background: transparent;
padding: 0;
font-size: inherit;
}
.docs-content blockquote {
border-left: 3px solid var(--border);
border-radius: 0.4rem;
background: var(--muted);
padding: 0.85rem 1.1rem;
font-size: 0.95rem;
}
.docs-content blockquote p {
margin: 0;
}
.docs-content table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.docs-content th {
text-align: left;
color: var(--foreground);
border-bottom: 1px solid var(--border);
padding: 0.5rem 0.9rem 0.5rem 0;
white-space: nowrap;
}
.docs-content td {
border-bottom: 1px solid var(--border);
padding: 0.5rem 0.9rem 0.5rem 0;
vertical-align: top;
}
.docs-content td code {
white-space: nowrap;
}
.docs-content img {
max-width: 100%;
border-radius: 0.6rem;
border: 1px solid var(--border);
}
.docs-content hr {
border: none;
border-top: 1px solid var(--border);
margin: 2.5rem 0;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
export interface DocsNavItem {
title: string;
href: string;
}
export interface DocsNavSection {
title: string;
items: DocsNavItem[];
}
export const docsNav: DocsNavSection[] = [
{
title: 'Getting Started',
items: [
{ title: 'Quickstart', href: '/docs/quickstart/' },
{ title: 'Deployment', href: '/docs/deployment/' },
],
},
{
title: 'Configuration',
items: [
{ title: 'Configuration Guide', href: '/docs/configuration/' },
{ title: 'Environment Variables', href: '/docs/environment-variables/' },
],
},
{
title: 'Features',
items: [
{ title: 'Notifications', href: '/docs/notifications/' },
{ title: 'Authentication & SSO', href: '/docs/authentication/' },
{ title: 'Force-Push Protection', href: '/docs/force-push-protection/' },
],
},
{
title: 'Reference',
items: [
{ title: 'Architecture', href: '/docs/architecture/' },
{ title: 'Advanced', href: '/docs/advanced/' },
{ title: 'Custom CA Certificates', href: '/docs/ca-certificates/' },
],
},
];
+212
View File
@@ -0,0 +1,212 @@
---
layout: '../../layouts/DocsLayout.astro'
title: Advanced Topics
description: Health checks, database management, crash recovery, graceful shutdown, and how the scheduler actually behaves.
---
# Advanced Topics
Operational detail for people running Gitea Mirror seriously: monitoring it, maintaining its database, and understanding what happens when it restarts mid-job.
## Health endpoint
`GET /api/health` returns a JSON summary of application health. It requires no authentication, which is what lets container orchestrators and uptime monitors poll it.
```bash
curl http://localhost:4321/api/health
```
A healthy instance responds with HTTP 200 and a body like this:
```json
{
"status": "ok",
"timestamp": "2026-08-03T12:00:00.000Z",
"version": "3.24.0",
"latestVersion": "3.24.0",
"updateAvailable": false,
"database": {
"connected": true
},
"recovery": {
"status": "healthy",
"jobsNeedingRecovery": 0
}
}
```
### Fields
`status` is the overall verdict and is one of three values. `ok` means everything is fine. `degraded` means the database is reachable but there are interrupted jobs waiting to be recovered and no recovery pass is currently running. `error` means the database connectivity check failed.
`timestamp` is when the check ran, in ISO 8601.
`version` is the running version, read from `npm_package_version`. It is the string `unknown` if that variable is not set, which happens when the process is started without the Docker entrypoint, since the entrypoint is what extracts it from `package.json`.
`latestVersion` is the newest published release, fetched from the GitHub releases API and cached in memory for one hour. It is `unknown` if the lookup fails, for example on an instance with no outbound internet access.
`updateAvailable` is `true` only when both versions are known and the running one compares as older.
`database.connected` is the result of a trivial query against `sqlite_master`.
`recovery.status` is `healthy` when nothing needs recovering, `jobs-pending` when interrupted jobs were found, or `error` if the check itself failed. `recovery.jobsNeedingRecovery` is a simplified flag rather than a true count: it is `1` when any job needs recovery, `0` when none do, and `-1` when the check errored.
If the handler throws, the endpoint returns HTTP 503 with a sanitized error body instead of the structure above.
> **Note:** The response deliberately contains no memory, CPU, uptime, or operating system details. The endpoint is unauthenticated, and exposing host telemetry on an unauthenticated route is an information disclosure risk. If you need those metrics, collect them from the container runtime rather than from the application.
### Container health checks
Both the `Dockerfile` and the compose files already wire this up, so `docker ps` reports health without any extra configuration. The check accounts for `BASE_URL`, polling `http://localhost:4321${BASE_URL}/api/health`. The image-level check runs every 30 seconds with a 5 second timeout, a 5 second start period, and 3 retries.
For Kubernetes, point both a readiness and a liveness probe at the same path:
```yaml
readinessProbe:
httpGet:
path: /api/health
port: 4321
initialDelaySeconds: 15
periodSeconds: 30
livenessProbe:
httpGet:
path: /api/health
port: 4321
initialDelaySeconds: 30
periodSeconds: 30
```
> **Note:** A `degraded` status still returns HTTP 200, so a liveness probe will not restart the pod over pending job recovery, which is the behavior you want since recovery resolves itself. Alert on the `status` field if you want visibility into `degraded`.
## Database management
Gitea Mirror uses SQLite through Drizzle ORM. Migrations live in the `drizzle/` directory and run automatically on startup, so a normal upgrade requires nothing from you.
### Schema and migrations
```bash
# Generate a migration from changes to src/lib/db/schema.ts
bun run db:generate
# Apply pending migrations
bun run db:migrate
# Push schema changes directly, skipping migration files (development only)
bun run db:push
# Verify schema consistency
bun run db:check
# Pull the schema from an existing database
bun run db:pull
# Open Drizzle Studio, a browser-based database explorer
bun run db:studio
```
> **Warning:** `db:push` writes schema changes without producing a migration file. It is convenient during development and wrong for anything shared, because the next person to run `db:migrate` will not get your change. Use `db:generate` followed by `db:migrate` for anything that ships.
### Maintenance commands
The `manage-db` script handles operational tasks:
```bash
bun run manage-db init # Initialize a fresh database
bun run manage-db check # Report on database health
bun run manage-db fix # Repair common integrity problems
bun run manage-db cleanup # Remove old events and stale records
bun run manage-db auto # Initialize if missing, otherwise check
bun run manage-db reset-users # Delete all user accounts
bun run manage-db reset-password # Reset a user's password
```
Several of these have shorthand aliases in `package.json`: `bun run init-db`, `bun run check-db`, `bun run fix-db`, `bun run reset-users`, and `bun run reset-password`.
To delete the database file entirely and start over:
```bash
bun run cleanup-db
```
> **Warning:** `cleanup-db` removes `data/gitea-mirror.db` outright, and `manage-db reset-users` deletes every account including the admin. Neither asks for confirmation. Back up the `data` directory first.
### Backups
The entire application state is one SQLite file plus the two generated secret files, all inside `data/`. Backing up that directory backs up everything: configuration, repository records, job history, and the keys needed to decrypt stored tokens.
Stop the container before copying the file, or use SQLite's own backup mechanism, so you do not capture a partially written page.
## Crash recovery
Mirroring a large repository with metadata can take a long time, and a restart in the middle of it would otherwise leave jobs stranded in a `mirroring` state forever. Gitea Mirror handles this in two places.
On startup, a recovery pass finds jobs that were interrupted and resumes or fails them cleanly. In Docker this runs automatically from the entrypoint with a 30 second timeout, before the server starts accepting requests. You can run it manually:
```bash
bun run startup-recovery # Normal recovery pass
bun run startup-recovery-force # Force recovery of all interrupted jobs
```
The entrypoint also runs a repository status repair pass, which reconciles repositories whose recorded state disagrees with what actually exists in Gitea.
To exercise the recovery path without waiting for a real crash:
```bash
bun run test-recovery
bun run test-recovery-cleanup # Remove the test artifacts afterwards
```
## Graceful shutdown
Gitea Mirror installs handlers for `SIGTERM`, `SIGINT`, and `SIGHUP`, plus uncaught exceptions and unhandled promise rejections. When any of these fires, a shutdown manager coordinates termination: it tracks running jobs, persists their progress to the database, runs cleanup callbacks that services have registered, and only then exits.
Two timeouts bound the process: 30 seconds for the shutdown overall, and 10 seconds per job to save its state. These prevent a hung job from blocking termination indefinitely.
The Docker entrypoint traps signals and forwards them to the application process rather than letting the shell absorb them, so `docker stop` and Kubernetes pod termination both produce a clean shutdown instead of a kill.
Jobs interrupted this way are marked as interrupted rather than failed, which is what the startup recovery pass looks for on the next boot.
To test it:
```bash
bun run test-shutdown
bun run test-shutdown-cleanup
```
## Scheduling behavior
The scheduler is more than a timer, and a few of its behaviors are worth knowing.
### Interval formats
Any interval setting accepts a duration string (`30m`, `8h`, `7d`), a five-field cron expression (`0 2 * * *`), or a plain number interpreted as seconds. The parser tries cron first when the value has five whitespace-separated fields, then falls back to duration parsing, then to a bare seconds value.
Recognized duration units are `ms`, `s`, `m`, `h`, `d`, and `w`, in both short and long spellings.
### What a scheduled run does
A run proceeds in stages, each gated by its own setting. First, if auto-import is enabled, it queries GitHub for repositories that are not yet tracked and adds them. Then, if auto-mirror is enabled, it mirrors repositories that need mirroring.
Auto-mirroring of starred repositories is governed separately from auto-mirroring of your own repositories, by the `AUTO_MIRROR_STARRED` setting. This exists because starred repositories are often imported purely for browsing, and mirroring hundreds of other people's projects is usually not what you want by default.
Existing mirrors are re-synced according to the configured interval, and optionally skipped when they were mirrored very recently or have no upstream changes.
### Auto-start
Setting `SCHEDULE_ENABLED=true`, or any of `SCHEDULE_INTERVAL`, `GITEA_MIRROR_INTERVAL`, or `DELAY`, enables the scheduler. When enabled, the first run happens shortly after boot rather than waiting a full interval, so a fresh container populates itself without anyone clicking a button.
### Mirror intervals versus sync intervals
There are two independent intervals and they are easy to confuse.
`GITEA_MIRROR_INTERVAL` is stored on each repository in Gitea and controls how often Gitea itself pulls from GitHub. Gitea's own default is 8 hours, and Gitea Mirror sets this value explicitly rather than leaving it to the server default.
The scheduler interval controls how often Gitea Mirror wakes up to discover new repositories, mirror pending ones, and run cleanup. It does not pull code itself.
You generally want the scheduler interval to be no shorter than the mirror interval, since running it more often will not make Gitea fetch any sooner.
## Rate limits
GitHub allows 5,000 authenticated API requests per hour on a personal access token. Gitea Mirror uses Octokit with the throttling plugin, which backs off automatically when it approaches the limit, and tracks remaining quota so the dashboard can show it.
The batch size and concurrency settings are the main levers if you are hitting limits: lower `SCHEDULE_BATCH_SIZE`, `MIRROR_ISSUE_CONCURRENCY`, and `MIRROR_PULL_REQUEST_CONCURRENCY`, or raise `SCHEDULE_PAUSE_BETWEEN_BATCHES`. Metadata mirroring is by far the most request-hungry operation, since it walks every issue, comment, and label individually.
+196
View File
@@ -0,0 +1,196 @@
---
layout: '../../layouts/DocsLayout.astro'
title: Architecture
description: How Gitea Mirror is put together, from the request path through the mirroring pipeline to the deployment options.
---
# Architecture
Gitea Mirror is a single self-hosted process. It serves a web interface, talks to the GitHub and Gitea APIs, keeps state in a local SQLite file, and runs background schedulers in the same process. There is no separate worker, queue server, or external database to operate.
## Stack
The frontend is Astro 5 in server-side rendering mode, with React 19 islands for the interactive parts, Shadcn UI components, and Tailwind CSS 4.
The backend is Astro API routes served through the `@astrojs/node` adapter in standalone mode. Everything runs on the Bun runtime, which requires version 1.2.9 or newer.
Persistence is SQLite accessed through Drizzle ORM, using Bun's native SQLite driver. Authentication is Better Auth with session cookies. GitHub access goes through Octokit with the throttling plugin, and Gitea access goes through its REST API directly.
## Request path
A request arrives at `scripts/runtime-server.ts`, a thin Node HTTP server that exists to apply `BASE_URL` path rewriting before handing off to the Astro handler. This is why the same prebuilt image can be served at the root or under a path prefix without rebuilding.
From there `src/middleware.ts` runs on every request. It does more than the name suggests: alongside session validation and header-authentication bridging, it is where background services are lazily started on first request. The recovery pass, the activity log cleanup service, the mirroring scheduler, the repository cleanup service, the shutdown manager, and the environment configuration loader are all initialized here, each guarded by a flag so they start exactly once.
The request then reaches either a page under `src/pages/` or an API route under `src/pages/api/`.
## Source layout
```
src/
├── components/ React components
│ ├── ui/ Shadcn UI primitives
│ ├── config/ Configuration page forms and tabs
│ ├── repositories/ Repository table and actions
│ ├── organizations/ Organization cards and destination overrides
│ ├── dashboard/ Dashboard widgets
│ ├── activity/ Activity log views
│ ├── auth/ Sign-in and sign-up
│ ├── oauth/ OAuth provider flows
│ ├── layout/ Shell, sidebar, navigation
│ ├── theme/ Light and dark mode
│ └── sponsors/ Sponsor display
├── layouts/ Astro page layout
├── pages/ Astro pages and API routes
│ ├── api/ Server endpoints
│ │ ├── auth/ Better Auth handlers
│ │ ├── sso/ SSO and OIDC provider management
│ │ ├── github/ GitHub operations and connection testing
│ │ ├── gitea/ Gitea operations and connection testing
│ │ ├── config/ Configuration read and write
│ │ ├── repositories/ Repository listing and actions
│ │ ├── organizations/ Organization listing and overrides
│ │ ├── sync/ Mirror and sync triggers
│ │ ├── job/ Job management
│ │ ├── activities/ Activity log
│ │ ├── dashboard/ Dashboard aggregates
│ │ ├── cleanup/ Cleanup triggers
│ │ ├── notifications/ Notification settings and test sends
│ │ ├── rate-limit/ GitHub rate limit status
│ │ ├── events/ Event stream plumbing
│ │ ├── sse/ Server-sent events subscription
│ │ └── health.ts Health check
│ ├── index.astro Dashboard
│ ├── repositories.astro
│ ├── organizations.astro
│ ├── activity.astro
│ ├── config.astro
│ ├── login.astro
│ └── signup.astro
├── lib/ Business logic
│ ├── db/ Drizzle schema, client, Better Auth adapter, migration repairs
│ ├── utils/ Encryption, duration parsing, concurrency, config mapping
│ ├── providers/ Notification providers: ntfy, Apprise, Gotify, webhook
│ ├── sso/ OIDC configuration
│ ├── events/ Real-time event fan-out
│ ├── modules/ Module registry
│ ├── polyfills/ Runtime shims
│ ├── github.ts GitHub client
│ ├── gitea.ts Gitea client
│ ├── gitea-enhanced.ts Metadata mirroring
│ ├── scheduler-service.ts
│ ├── cleanup-service.ts
│ ├── repository-cleanup-service.ts
│ ├── auth.ts Better Auth configuration
│ ├── recovery.ts Interrupted job recovery
│ ├── shutdown-manager.ts
│ └── signal-handlers.ts
├── hooks/ React hooks: useAuth, useSyncRepo, useLiveRefresh, and others
├── types/ Shared TypeScript types
├── data/ Static data, currently the sidebar definition
├── styles/ Global CSS
├── tests/ Test setup and fetch mocking
└── middleware.ts Astro middleware, also the service bootstrap point
```
Outside `src/`:
- `drizzle/` holds the generated SQL migrations and their journal. Migrations run automatically on startup.
- `scripts/` holds the runtime server, database management CLI, recovery and repair scripts, and the environment configuration loader entry point.
- `helm/gitea-mirror/` is the Kubernetes Helm chart.
- `www/` is the marketing and documentation site, a separate Astro project with its own dependencies.
- `flake.nix` and `bun.nix` define the Nix package and NixOS module.
Tests are colocated with the code they cover, as `*.test.ts` files next to the implementation. They run on Bun's built-in test runner.
## Data model
Everything lives in one SQLite database. The tables that matter:
`configs` holds per-user configuration as JSON columns validated by Zod schemas: `githubConfig`, `giteaConfig`, `scheduleConfig`, `cleanupConfig`, and `notificationConfig`. A user can have several configuration rows but one is marked active.
`repositories` tracks every discovered repository with its GitHub metadata, its mirror status, its destination in Gitea, and any per-repository overrides.
`organizations` tracks GitHub organizations, including destination overrides that let one organization go somewhere different from what the global strategy would choose.
`mirrorJobs` is the job record and history. Jobs move through `pending`, `mirroring`, and then `success` or `failed`.
`activities` is the activity log shown on the dashboard, trimmed by the cleanup service according to the configured retention.
`user`, `session`, and `account` are Better Auth's tables, accessed through a custom SQLite adapter in `src/lib/db/adapter.ts`.
### Token encryption
GitHub and Gitea tokens are encrypted with AES-256-GCM before being written, using `ENCRYPTION_SECRET` as the key. This applies whether the token was entered through the UI or supplied through an environment variable. Reading a token back goes through helpers in `src/lib/utils/config-encryption.ts` rather than touching the column directly.
## Mirroring pipeline
A mirror operation runs in stages.
Discovery queries GitHub for repositories matching the configured selection rules, filtered by visibility, fork status, archive status, collaborator affiliation, and organization allowlists and denylists. Results are written to the `repositories` table.
Destination resolution decides where each repository goes, based on the mirror strategy plus any per-repository or per-organization override. The four strategies are `preserve`, `single-org`, `flat-user`, and `mixed`, described in the [configuration guide](./configuration.md).
Repository creation calls Gitea's migration API to create a pull mirror, creating the destination organization first if it does not exist. Organization creation is sequential by default to avoid races when many repositories target the same new organization.
Metadata mirroring, if enabled, follows separately. Issues are recreated with their comments, labels, assignees, and milestones. Releases are recreated with their assets. Wiki content is cloned as a separate repository.
Throughout, job status updates are published as server-sent events so the dashboard reflects progress live without polling.
### Pull requests become issues
Gitea's API cannot create a pull request through the migration path, so mirrored pull requests are created as issues instead. Each one is tagged with a `pull-request` label, its title is prefixed with the original number and status, and its body includes the commit history, changed files, and merge outcome. This is a deliberate workaround for an API limitation, not a temporary gap.
### Ordering and concurrency
Gitea assigns issue numbers on creation, so processing issues in parallel can produce numbering that does not match GitHub's. The concurrency settings default to 3 for issues and 5 for pull requests, trading some ordering fidelity for speed. Set both to 1 if you need the numbering to line up.
## Real-time updates
The dashboard subscribes to `/api/sse` and receives job and repository events as they happen. The publishing side is `src/lib/events.ts` and `src/lib/events/realtime.ts`. This is what makes a running mirror visibly progress rather than requiring a refresh.
## Background services
Four services run inside the main process, started from the middleware on first request:
The **scheduler** performs periodic mirroring, repository discovery, and auto-mirroring, on an interval or cron schedule.
The **cleanup service** trims the activity log according to the configured retention period.
The **repository cleanup service** handles repositories that have disappeared from GitHub, archiving or deleting them according to the configured action.
The **shutdown manager** tracks running jobs and persists their state when a termination signal arrives, so an interrupted job can be resumed on the next boot instead of being lost.
## Authentication
Better Auth handles sessions, with three sign-in paths.
Email and password authentication is always available. The first account to sign up becomes the admin.
OIDC and SSO providers are registered through the Authentication tab rather than through configuration files, so adding a provider does not require a restart.
Header authentication trusts identity headers set by an upstream reverse proxy, for deployments where something like Authentik or Authelia already handles login. It can auto-provision accounts, optionally restricted to an email domain allowlist.
Sessions are cookie-based and validated in the middleware on every request.
## Deployment options
**Docker** is the primary path. A multi-architecture image is published to `ghcr.io/raylabshq/gitea-mirror`, built from a multi-stage `Dockerfile` on the official Bun base image, with a statically compiled `git-lfs` binary included for LFS mirroring. Three compose files ship with the repository: `docker-compose.alt.yml` for a minimal prebuilt deployment, `docker-compose.yml` for a full environment-driven one, and `docker-compose.dev.yml` for development with hot reload.
**Kubernetes** is supported through the Helm chart in `helm/gitea-mirror`. It packages a Deployment, Service, ConfigMap, Secret, optional PersistentVolumeClaim, optional ServiceAccount, and a choice of Ingress or Gateway API HTTPRoute. It needs Kubernetes 1.23 or newer and Helm 3.8 or newer.
**Nix** deployment works either as a flake package or as a NixOS module. `nix run github:RayLabsHQ/gitea-mirror` starts it with no setup, and the NixOS module handles secret generation and database initialization declaratively.
**LXC** on Proxmox VE is available through the Proxmox VE Community Scripts project, which installs Gitea Mirror as a systemd service inside a container.
**Bare metal** means installing Bun, running `bun run setup` and `bun run build`, and starting `bun run start`. This is also the development setup.
## Design decisions worth knowing
**SQLite, not Postgres.** The workload is a single writer with modest data volume, and SQLite removes an entire operational dependency. The tradeoff is that horizontal scaling is not on the table, which is the right trade for a self-hosted mirroring tool.
**Services in-process, not separate workers.** There is no queue broker and no worker fleet. Job state lives in the database and recovery on startup handles interruption, which is simpler to operate than a distributed queue and sufficient for the concurrency this workload needs.
**Configuration in the database, not in files.** Environment variables can seed configuration, but the source of truth is the database so that settings can be changed through the UI without a restart. The consequence is that seeded values overwrite UI changes on every startup, which is why you should manage any given setting through one mechanism or the other.
**No memory or OS detail in the health response.** The health endpoint is unauthenticated by design so orchestrators can poll it, which means anything it returns is public. Host telemetry is deliberately left out.
+260
View File
@@ -0,0 +1,260 @@
---
layout: '../../layouts/DocsLayout.astro'
title: Authentication
description: Sign in with email and password, an external OIDC or SAML identity provider, or trusted headers from a reverse proxy.
---
# Authentication
Gitea Mirror handles sign-in through [Better Auth](https://better-auth.com) with session cookies. Three methods can get a user into the app: email and password, single sign-on against an external identity provider, and header authentication behind a reverse proxy. Email and password is always available. The other two are additive, so turning on SSO does not switch off the local login.
Gitea Mirror can also act as an OIDC provider for other applications, which is covered at the end of this page.
## Email and password
This method is always enabled and needs no configuration.
### Creating the first account
Visit `/signup` on a fresh instance and create an account. That account owns the instance configuration, which is what "first user becomes admin" means in practice.
Once one account exists, `/signup` redirects to `/login`. There is no open registration, so a public instance cannot accumulate accounts on its own. Additional users are created through header auth provisioning or SSO, not through the signup form.
### Sessions
Sessions are cookie based. The cookie is named `better-auth-session` and lasts 30 days, refreshed as the user stays active.
### Resetting a password
There is no email-driven password reset flow, because Gitea Mirror does not send email. Reset from the command line instead:
```bash
bun run reset-password -- --email=user@example.com --new-password='new-secure-password'
```
> **Note:** Accounts in Gitea Mirror are never email-verified, since no verification flow exists. This matters for SSO account linking, described below.
### Required secret
Set `BETTER_AUTH_SECRET` to a random string of at least 32 characters. It signs session tokens.
```bash
openssl rand -base64 32
```
If you leave it unset, the Docker entrypoint generates one and stores it in `/app/data/.better_auth_secret` so sessions survive a restart. Losing that file logs everyone out.
### URL configuration
Better Auth validates the origin of every request, so it needs to know your real external URL.
| Variable | Purpose |
|---|---|
| `BETTER_AUTH_URL` | The external origin of the instance, for example `https://mirror.example.com`. |
| `PUBLIC_BETTER_AUTH_URL` | The same value, exposed to the browser bundle. |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Comma-separated extra origins allowed to submit auth requests. |
| `BASE_URL` | Path prefix when the app is served under a subpath, for example `/mirror`. |
> **Warning:** Behind a reverse proxy, set `BETTER_AUTH_URL`, `PUBLIC_BETTER_AUTH_URL`, and `BETTER_AUTH_TRUSTED_ORIGINS` to your external HTTPS URL. A mismatch usually shows up as a 500 error or an invalid origin message right after login.
The app also auto-detects the origin from the incoming `X-Forwarded-Host` and `X-Forwarded-Proto` headers, which covers many proxy setups without extra configuration. Setting the variables explicitly is still the reliable path.
## Single sign-on
SSO lets users authenticate against an external identity provider such as Google, Okta, Authentik, or Azure AD. Providers are managed in the UI under **Configuration > Authentication > SSO Providers**.
Both OIDC and SAML 2.0 providers are supported. You choose the protocol on a tab when adding a provider.
### Shared fields
Every provider, regardless of protocol, needs these:
| Field | Description |
|---|---|
| Issuer URL | The identity provider's issuer, for example `https://accounts.google.com`. Keep any trailing slash your provider shows. |
| Domain | The email domain this provider serves. Multi-domain providers take a comma-separated list. This field controls account linking, see below. |
| Provider ID | A short unique slug such as `google-sso`. It appears in the callback URL, so pick it before configuring the upstream provider. |
### OIDC providers
Add these on top of the shared fields:
| Field | Description |
|---|---|
| Client ID | OAuth client ID issued by your provider. |
| Client Secret | OAuth client secret. |
| Scopes | Defaults to `openid`, `email`, `profile`. |
| PKCE | Enabled by default. Leave it on unless your provider cannot handle it. |
Enter the Issuer URL and click **Discover** to fetch the authorization, token, JWKS, and userinfo endpoints automatically. For providers without a discovery document, fill those four endpoints in by hand.
> **Warning:** Google rejects the `offline_access` scope. Remove it from the scope list for Google providers or the sign-in fails.
The redirect URL to register with your provider is:
```text
https://your-domain.com/api/auth/sso/callback/{provider-id}
```
Replace `{provider-id}` with the Provider ID you chose. The UI shows the exact URL as you type, so you can copy it straight into the provider console.
#### Example: Google
1. In Google Cloud Console, create an OAuth 2.0 Client ID.
2. Add `https://your-domain.com/api/auth/sso/callback/google-sso` as an authorized redirect URI.
3. In Gitea Mirror, set Issuer URL to `https://accounts.google.com`, Provider ID to `google-sso`, Domain to your company email domain, then paste the client ID and secret.
4. Click **Discover**, then save.
#### Example: Authentik
1. Create an Application and an OIDC Provider in Authentik.
2. Start adding the provider in Gitea Mirror so you can copy the redirect URL it displays.
3. Paste that redirect URL into the Authentik provider, then finish creating it.
4. Back in Gitea Mirror, paste the issuer URL, client ID, and client secret, click **Discover**, and set Domain to the email domain your Authentik users actually have.
### SAML 2.0 providers
Switch to the **SAML 2.0** tab when adding a provider, then supply:
| Field | Description |
|---|---|
| SAML Entry Point | Your identity provider's SSO URL, where authentication requests are sent. |
| X.509 Certificate | The signing certificate from your identity provider, used to validate assertions. |
| Callback URL | Optional. Defaults to the URL below if you leave it blank. |
The assertion consumer service URL is:
```text
https://your-domain.com/api/auth/sso/saml2/callback/{provider-id}
```
Gitea Mirror also publishes service provider metadata, which many identity providers can import directly:
```text
https://your-domain.com/api/auth/sso/saml2/sp/metadata?providerId={provider-id}
```
The default name ID format is `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`, which keeps identities aligned with the email-based account matching described next.
### Account linking
When someone signs in through SSO with an email that already has a local account, Gitea Mirror links the two so they land on the same data instead of getting a duplicate account or an error page. Matching is by email address.
The trust model is scoped to the Domain field you set on the provider:
- Registering a provider marks it as verified for the domains you listed.
- A sign-in is auto-linked only when the user's email actually belongs to one of those domains. A cross-domain email is refused even though the provider is registered.
- Local accounts are never email-verified in this app, so the linker deliberately does not require local verification. Otherwise linking could never happen.
> **Warning:** Set Domain to the email domains your identity provider genuinely issues. If that provider lets users self-register or claim arbitrary addresses within the domain, someone there can claim an address matching a local account and take it over. Registering a provider means vouching for its identity model for those domains.
### Signing in
When at least one provider is configured, the login page shows **Email** and **SSO** tabs. On the SSO tab a user either clicks a provider button or enters their work email, which routes them to the provider matching that email domain.
## Header authentication
Header authentication trusts an upstream reverse proxy such as Authentik, Authelia, oauth2-proxy, or Traefik forward-auth to authenticate the user and pass identity along in HTTP headers. There is no login form and no callback: Gitea Mirror reads the headers and resolves the user.
This method is configured through environment variables only, never in the UI. Trusting a header means trusting whoever can set it, and that is a decision for whoever controls the proxy, not for a user logged into the app.
### Environment variables
| Variable | Description | Default |
|---|---|---|
| `HEADER_AUTH_ENABLED` | Master switch, must be `true` to enable | `false` |
| `HEADER_AUTH_USER_HEADER` | Header carrying the username | `X-Authentik-Username` |
| `HEADER_AUTH_EMAIL_HEADER` | Header carrying the email address | `X-Authentik-Email` |
| `HEADER_AUTH_NAME_HEADER` | Header carrying the display name | `X-Authentik-Name` |
| `HEADER_AUTH_AUTO_PROVISION` | Create an account when an unknown username arrives | `false` |
| `HEADER_AUTH_ALLOWED_DOMAINS` | Comma-separated email domain allowlist, empty allows any | empty |
The defaults are Authentik shaped. For an Authentik proxy provider you usually only need to set `HEADER_AUTH_ENABLED=true`, plus `HEADER_AUTH_AUTO_PROVISION=true` if you want first-login self-registration.
### Example: Authentik
```yaml
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
environment:
HEADER_AUTH_ENABLED: "true"
HEADER_AUTH_AUTO_PROVISION: "true"
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,example.org"
```
### Example: Authelia
Authelia uses different header names, so override all three:
```yaml
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
environment:
HEADER_AUTH_ENABLED: "true"
HEADER_AUTH_USER_HEADER: "Remote-User"
HEADER_AUTH_EMAIL_HEADER: "Remote-Email"
HEADER_AUTH_NAME_HEADER: "Remote-Name"
HEADER_AUTH_AUTO_PROVISION: "true"
```
Then configure Authelia's authorization rules to protect the Gitea Mirror route and inject the `Remote-*` headers.
### How a user is resolved
1. An existing session cookie wins. Header auth only fires when there is no session, so users who signed in with a password or through SSO are unaffected.
2. The username header is matched against the `username` column.
3. If that finds nothing and an email header is present, the email is matched against the `email` column.
4. If neither matches and `HEADER_AUTH_AUTO_PROVISION` is `true`, a new account is created. When no email header is present the address falls back to `<username>@header-auth.local`.
5. If neither matches and auto-provisioning is off, the request is rejected and a warning is logged.
When `HEADER_AUTH_ALLOWED_DOMAINS` is set and an email header arrives, the email's domain must appear in the list or the request is rejected before any lookup happens.
### Security requirements
> **Warning:** Your reverse proxy must strip inbound copies of the configured headers from client requests before injecting its own. Without that, anyone who can reach the app can send `X-Authentik-Username: admin` and walk straight in.
Beyond stripping headers:
1. **Do not expose the port directly.** When header auth is on, only the trusted proxy should be able to reach Gitea Mirror. Bind it to an internal network.
2. **Terminate TLS at the proxy.** Headers travel in plaintext over whatever link carries them.
3. **Be conservative with auto-provisioning.** With it off you create accounts once, and the proxy just fills in sessions afterwards. That is the safer default for shared deployments.
### Confirming it is active
The Authentication settings page shows a green "Header Authentication / Via reverse proxy" badge when the feature is live. You can also check the API:
```bash
curl https://your-domain.com/api/auth/header-status
```
It returns `{"enabled": true, ...}` once the variables are set.
## Gitea Mirror as an OIDC provider
Gitea Mirror can also issue identities to other applications. Create an OAuth application under **Configuration > Authentication > OAuth Applications** and share the resulting client ID and secret with the relying application.
Standard endpoints:
| Purpose | URL |
|---|---|
| Discovery | `https://your-domain.com/.well-known/openid-configuration` |
| Authorization | `https://your-domain.com/api/auth/oauth2/authorize` |
| Token | `https://your-domain.com/api/auth/oauth2/token` |
| UserInfo | `https://your-domain.com/api/auth/oauth2/userinfo` |
| JWKS | `https://your-domain.com/api/auth/jwks` |
Supported scopes are `openid` for the user ID, `profile` for name and username, and `email` for the address and its verification status. Users see a consent screen the first time an application asks for access.
## Troubleshooting
**Invalid origin errors.** `BETTER_AUTH_URL` does not match the URL users actually visit. Set it, `PUBLIC_BETTER_AUTH_URL`, and `BETTER_AUTH_TRUSTED_ORIGINS` to the external origin.
**Redirected to the homepage with `?error=UNKNOWN` after a successful SSO login.** The upstream login worked but linking to the existing local account was refused. The usual cause is a Domain field that does not match the user's real email domain. Set `BETTER_AUTH_LOG_LEVEL=debug` to see the full callback trace, and look for a message about an account not being linked.
**`TypeError: undefined is not an object (evaluating 'url.startsWith')`.** The stored provider record is missing its OIDC endpoints. Delete the provider and add it again using the **Discover** button so the endpoints are saved.
**Redirect loop on SSO.** The redirect URI registered upstream does not exactly match `/api/auth/sso/callback/{provider-id}`. Trailing slashes and http versus https both count as a mismatch.
**Header auth does nothing.** Confirm `HEADER_AUTH_ENABLED` is the string `"true"`, then check `/api/auth/header-status`. Also confirm the proxy is actually setting the header names you configured, since the defaults are Authentik specific.
+249
View File
@@ -0,0 +1,249 @@
---
layout: '../../layouts/DocsLayout.astro'
title: CA Certificates
description: Configure Gitea Mirror to trust a self-signed or privately signed Gitea instance without disabling TLS verification.
---
# CA Certificates
If your Gitea or Forgejo instance uses a self-signed certificate, or one issued by an internal Certificate Authority, Gitea Mirror will refuse to connect until it is told to trust that CA. This page covers how to supply the certificate for each deployment method.
## Recognizing the problem
These errors all mean the certificate chain could not be verified:
```
UNABLE_TO_VERIFY_LEAF_SIGNATURE
SELF_SIGNED_CERT_IN_CHAIN
UNABLE_TO_GET_ISSUER_CERT_LOCALLY
CERT_UNTRUSTED
unable to verify the first certificate
```
They surface when you use the test button on the Gitea Connection card, and in the application logs during mirroring.
> **Warning:** There is a `GITEA_SKIP_TLS_VERIFY` setting that makes these errors go away by disabling certificate verification. It sets `NODE_TLS_REJECT_UNAUTHORIZED=0` for the entire process, which turns off verification for every outbound HTTPS request, including those to GitHub. That leaves you open to interception on all of them. Add the CA properly instead.
## Docker
The container image handles this for you. On startup, the entrypoint scans `/app/certs` for files ending in `.crt`, concatenates them into a bundle at `/app/certs/ca-bundle.crt`, and sets `NODE_EXTRA_CA_CERTS` to point at it. If the system CA bundle inside the container is writable, it appends the certificates there too, so Bun's own TLS stack picks them up as well.
That means the whole setup is a volume mount.
Create a directory next to your compose file and put the certificate in it:
```bash
mkdir -p ./certs
cp /path/to/your-ca-cert.crt ./certs/
```
Mount it read-only at `/app/certs`:
```yaml
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
container_name: gitea-mirror
restart: unless-stopped
ports:
- "4321:4321"
volumes:
- ./data:/app/data
- ./certs:/app/certs:ro
```
Recreate the container so the entrypoint runs again:
```bash
docker compose up -d --force-recreate
```
The startup logs confirm what happened. You should see a line naming each certificate it added, followed by the path it set:
```
Custom CA certificates found, configuring Node.js to use them...
Adding certificate: your-ca-cert.crt
NODE_EXTRA_CA_CERTS set to: /app/certs/ca-bundle.crt
```
If instead you see `No custom CA certificates found in /app/certs`, the mount did not land or the files do not end in `.crt`.
> **Note:** Only files matching `*.crt` are picked up. A certificate saved as `.pem` or `.cer` will be ignored even though the contents are fine. Rename it, the extension is all that matters here.
### Mounting an existing system bundle
If the host already has your CA in its trust store, you can mount the whole bundle instead of individual certificates:
```yaml
volumes:
- ./data:/app/data
- /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
```
The entrypoint detects that this path has been mounted over and sets `NODE_EXTRA_CA_CERTS` to it. This is convenient but it replaces the container's bundle wholesale, so make sure the host bundle includes the public roots and not just your private CA.
### Baking the certificate into an image
Rarely necessary, since the volume mount covers most cases, but useful if you distribute a preconfigured image internally:
```dockerfile
FROM ghcr.io/raylabshq/gitea-mirror:latest
USER root
COPY ./certs/*.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates
```
Build it:
```bash
docker build -t my-gitea-mirror .
```
The base image is Debian-based, so `update-ca-certificates` is available. Note that this writes into the system trust store rather than using `/app/certs`, so the entrypoint's bundling step is bypassed entirely.
## Bare metal with Bun
Point `NODE_EXTRA_CA_CERTS` at your certificate before starting the process.
As an environment variable:
```bash
export NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt
bun run start
```
Or in the `.env` file at the project root:
```bash
NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt
```
Alternatively, add the certificate to the system trust store, which makes it available to every program on the machine rather than just Gitea Mirror.
On Debian and Ubuntu:
```bash
sudo cp your-ca-cert.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
```
On RHEL, CentOS, and Fedora:
```bash
sudo cp your-ca-cert.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust
```
On macOS:
```bash
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain your-ca-cert.crt
```
## LXC on Proxmox
The LXC install runs Gitea Mirror as a systemd service. Enter the container:
```bash
pct enter <container-id>
```
Install the certificate into the system trust store:
```bash
mkdir -p /usr/local/share/ca-certificates
cat > /usr/local/share/ca-certificates/your-ca.crt
```
Paste the certificate contents and press Ctrl+D, then rebuild the bundle:
```bash
update-ca-certificates
```
To set `NODE_EXTRA_CA_CERTS` for the service, use a systemd drop-in rather than editing the unit file:
```bash
mkdir -p /etc/systemd/system/gitea-mirror.service.d
cat > /etc/systemd/system/gitea-mirror.service.d/ca-certs.conf <<'EOF'
[Service]
Environment=NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca.crt
EOF
```
Reload and restart:
```bash
systemctl daemon-reload
systemctl restart gitea-mirror
```
> **Warning:** Do not append `Environment=` lines to the end of `/etc/systemd/system/gitea-mirror.service`. The last section in that file is `[Install]`, and an `Environment=` directive placed there is not a valid `[Install]` option, so it is rejected rather than applied. A drop-in file puts the directive in `[Service]` where it belongs, and survives reinstalls of the main unit.
## Kubernetes
Create a ConfigMap or Secret holding the certificate, mount it into the pod, and point `NODE_EXTRA_CA_CERTS` at the mounted path:
```yaml
volumes:
- name: ca-certs
configMap:
name: gitea-mirror-ca
volumeMounts:
- name: ca-certs
mountPath: /app/certs
readOnly: true
```
Mounting at `/app/certs` lets the image's entrypoint do the bundling, so no environment variable is needed. Make sure the ConfigMap key ends in `.crt`, since the key becomes the filename.
## Multiple certificates
`NODE_EXTRA_CA_CERTS` accepts a single file, but that file can contain any number of PEM certificates one after another. Concatenate them:
```bash
cat ca-cert1.crt ca-cert2.crt ca-cert3.crt > ca-bundle.crt
export NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.crt
```
In Docker you do not need to do this yourself. Drop every certificate into `./certs` and the entrypoint concatenates them for you.
If you need the certificates trusted system-wide rather than just by this application, put them all in the trust store:
```bash
cp *.crt /usr/local/share/ca-certificates/
update-ca-certificates
```
## Verifying
Three checks, in increasing order of directness.
Use the test button on the Gitea Connection card in the Configuration page. It performs a real authenticated API call, so a success means the TLS chain verified.
Check the logs for TLS errors:
```bash
docker logs gitea-mirror # Docker
journalctl -u gitea-mirror -f # LXC or systemd
```
Test the chain outside the application entirely, which isolates whether the problem is the certificate or the application:
```bash
openssl s_client -connect your-gitea-domain.com:443 -CAfile /path/to/ca-cert.crt
```
Look for `Verify return code: 0 (ok)` near the end of the output. Anything else means the chain itself is the problem and no amount of application configuration will fix it.
## Troubleshooting
**The certificate is not being picked up.** Confirm it is in PEM format, which is the text format beginning with `-----BEGIN CERTIFICATE-----`. A DER-encoded file has the right extension but binary contents and will not work. Convert it with `openssl x509 -inform der -in cert.der -out cert.crt`. In Docker, confirm the filename ends in `.crt` and check the startup logs for the "Adding certificate" line. Restart the process after any change, since the trust store is read once at startup.
**Still getting errors after adding the CA.** The chain is probably incomplete. If your certificate was issued by an intermediate CA, you need both the intermediate and the root in the bundle, not just the root. Run the `openssl s_client` command above and read the certificate chain it prints to see which link is missing.
**Hostname mismatch.** A valid certificate for the wrong name still fails. Check that the URL you configured matches a name in the certificate's Subject Alternative Name list: `openssl x509 -in cert.crt -noout -text | grep -A1 "Subject Alternative Name"`.
**The certificate expired.** Check the validity window with `openssl x509 -in cert.crt -noout -dates`, get a fresh certificate from your CA, and restart Gitea Mirror. If this keeps happening, it is worth setting up expiry monitoring, or moving the instance to a publicly trusted certificate through Let's Encrypt so renewal is automatic and no client configuration is needed at all.
+196
View File
@@ -0,0 +1,196 @@
---
layout: '../../layouts/DocsLayout.astro'
title: Configuration
description: How Gitea Mirror is configured through the web interface, and how environment variables can pre-populate that configuration.
---
# Configuration
Gitea Mirror has two configuration layers. Almost everything lives in the web interface and is stored in the database per user. A smaller set of environment variables covers the things that have to be known before the app can serve a request, such as the session secret and the public URL, and can additionally seed the database configuration on first boot.
Start with the web interface. Reach for environment variables when you want a deployment that comes up fully configured without anyone clicking through a form.
## The Configuration page
![The Configuration page showing the Connections tab](/assets/configuration.png)
The Configuration page is organized into four tabs.
### Connections
This is where the two ends of the mirror are set up. It contains two cards:
**GitHub Connection** holds your GitHub username, personal access token, and account type (personal or organization). The token needs the `repo` scope for private repositories and `admin:org` to read organization data. This card also carries the repository selection options: whether to include private repositories, public repositories, archived repositories, forks, repositories where you are only a collaborator, and starred repositories.
**Gitea Connection** holds the Gitea or Forgejo instance URL, an access token, and the default owner. It also carries the destination settings: the mirror strategy, default organization, organization visibility, and the per-repository mirror interval that Gitea itself uses to re-pull from GitHub.
Both cards have a test button that performs a live authenticated call, so you can confirm credentials before saving.
If your Gitea instance is reachable at one URL from the Gitea Mirror process and at a different URL from your browser, for example an internal service name versus a public hostname, set the external URL as well. Dashboard links use the external URL while all API and mirroring traffic keeps using the primary URL.
### Automation
This tab controls scheduled mirroring and cleanup.
Scheduled syncing runs on an interval or a cron schedule. When enabled it re-syncs existing mirrors, and optionally discovers newly created GitHub repositories and mirrors them without any manual action. Two separate switches govern that: auto-import decides whether new repositories are discovered and added to the database, and auto-mirror decides whether they are then actually mirrored to Gitea.
The same tab holds repository cleanup, which handles repositories that have disappeared from GitHub. The action for an orphaned repository is one of `skip`, `archive`, or `delete`, and `archive` is the default because it preserves the mirrored data. There is a dry-run mode that logs what would happen without touching anything, and a list of protected repository names that cleanup will never act on.
### Notifications
Gitea Mirror can send a notification on sync failure, on sync success, and when a new repository is discovered. Four providers are supported: ntfy, Apprise, Gotify, and a generic webhook. Each has its own settings, and only one provider is active at a time.
The generic webhook sends a JSON POST containing `title`, `message`, `type`, and `timestamp` fields, which makes it straightforward to wire into anything that accepts an inbound hook.
### Authentication
This tab configures how people sign in. Email and password authentication is always available. On top of that you can register OIDC or SSO providers, and enable header authentication for deployments sitting behind a reverse proxy that already handles identity, such as Authentik or Authelia.
Header authentication reads the username, email, and display name from configurable request headers. It can optionally auto-provision accounts on first sight of a new user, restricted to an allowlist of email domains.
> **Warning:** Only enable header authentication when the app is genuinely behind a trusted proxy that strips those headers from inbound client requests. If clients can set the headers themselves, anyone can sign in as anyone.
## Mirror strategies
The mirror strategy decides where each repository lands on the Gitea side. There are four, and they are set on the Gitea Connection card.
### preserve
Keeps the same organization structure as GitHub. A repository at `acme/api` on GitHub becomes `acme/api` in Gitea, with the organization created if it does not exist. This is the default and the right choice when you want the mirror to be recognizable to anyone who knows the GitHub side.
### single-org
Consolidates every repository into one Gitea organization, regardless of where it came from on GitHub. The target organization defaults to `github-mirrors`. Useful when you want all mirrored content in one clearly separated namespace.
### flat-user
Places every repository directly under your Gitea user account, with no organizations involved. The simplest layout, and a reasonable fit for a personal instance with a modest number of repositories.
### mixed
Splits the difference. Your personal GitHub repositories go into a single named organization, while repositories owned by GitHub organizations preserve their original structure. This keeps organization repositories where people expect them while corralling your personal ones.
> **Note:** Repositories mirrored because you starred them are handled separately from the strategy above. They can either go into a dedicated organization, which is named `starred` by default, or preserve their original owner's path. When two starred repositories from different owners share a name, a duplicate strategy setting decides whether the name gets a suffix, a prefix, or is placed in an owner-named organization.
## Schedule intervals
Anywhere Gitea Mirror asks for an interval, it accepts several formats:
- A duration string with a unit: `30m`, `8h`, `7d`. The recognized units are `ms`, `s`, `m`, `h`, `d`, and `w`, and both short and long spellings work, so `8h` and `8 hours` are equivalent.
- A five-field cron expression: `0 2 * * *` for daily at 2 AM, `0 */6 * * *` for every six hours, `0 0 * * 0` for weekly on Sunday at midnight.
- A plain number, interpreted as seconds. `3600` means one hour.
Cron expressions are useful when you want syncing to happen during a specific low-traffic window rather than at a fixed offset from whenever the service last restarted.
## Configuring through environment variables
Environment variables serve two distinct purposes, and it is worth keeping them apart.
The first group is read directly by the running process at startup and has no equivalent in the UI: the port, the host, the session secret, the encryption secret, and the public URLs. These must be set as environment variables.
The second group seeds the database configuration. On startup, if any of `GITHUB_USERNAME`, `GITHUB_TOKEN`, `GITEA_URL`, `GITEA_USERNAME`, or `GITEA_TOKEN` is set, Gitea Mirror loads the whole environment configuration and writes it into the first user's config row. If no user exists yet, it does nothing and tries again on the next start.
> **Note:** Values written from the environment overwrite the corresponding fields in the database on every start. If you set `GITEA_ORGANIZATION` in your compose file and then change the organization through the UI, the next restart puts the environment value back. Either manage a setting through the UI or through the environment, not both.
Tokens supplied through the environment are encrypted with AES-256-GCM before they are stored, the same as tokens entered through the UI.
## Core environment variables
These are the ones that matter for nearly every deployment. The [environment variables reference](./environment-variables.md) has the complete list.
### Server
| Variable | Default | Description |
| --- | --- | --- |
| `NODE_ENV` | `development` | Runtime environment. The container image sets `production`. |
| `HOST` | `localhost` | Interface to bind. The container image sets `0.0.0.0`. |
| `PORT` | `4321` | Port to listen on. |
| `BASE_URL` | `/` | Path prefix when the app is served under a subpath, for example `/mirror`. |
| `DATABASE_URL` | `sqlite://data/gitea-mirror.db` | SQLite database location. The container image sets `file:data/gitea-mirror.db`. |
> **Note:** The database file is always created at `data/gitea-mirror.db` relative to the process working directory, and the directory is created if it is missing. `DATABASE_URL` is accepted and reported but the SQLite driver does not currently resolve an alternative path from it. To relocate the database, mount or symlink the `data` directory rather than changing this variable.
### Authentication and secrets
| Variable | Default | Description |
| --- | --- | --- |
| `BETTER_AUTH_SECRET` | none | Signs session cookies. Generate with `openssl rand -base64 32`. Required. |
| `BETTER_AUTH_URL` | `http://localhost:4321` | The origin the auth server runs on. Scheme and host only, no path. |
| `PUBLIC_BETTER_AUTH_URL` | none | The origin the browser should send auth requests to. Set this when the app is reachable at more than one origin. Scheme and host only. |
| `BETTER_AUTH_TRUSTED_ORIGINS` | none | Comma-separated list of additional origins allowed to make auth requests. |
| `ENCRYPTION_SECRET` | none | Key for AES-256-GCM encryption of stored GitHub and Gitea tokens. Generate with `openssl rand -base64 48`. |
`BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET` are both auto-generated by the Docker entrypoint if you leave them unset, and persisted inside the data volume so they survive restarts. Outside Docker there is no such fallback, and a missing `BETTER_AUTH_SECRET` in production logs a security warning and uses a known insecure development value.
The origin of `BETTER_AUTH_URL` is trusted automatically, so `BETTER_AUTH_TRUSTED_ORIGINS` is only for additional origins. Common reasons to add one: reaching the app by local IP as well as by domain name, an external SSO provider, or a reverse proxy on a different hostname.
### Multiple access URLs
To reach the app at both a local IP and a public domain:
```bash
BETTER_AUTH_URL=https://gitea-mirror.example.com
PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
BETTER_AUTH_TRUSTED_ORIGINS=http://10.10.20.45:4321,http://192.168.1.100:4321
```
Browsers isolate cookies per origin, so you will need to sign in separately on each origin. That is a browser security rule, not something the application can work around.
### Path prefix deployments
To serve the app at `https://git.example.com/mirror`:
```bash
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
```
The auth variables stay as bare origins. The prefix comes from `BASE_URL` and is applied when auth endpoints are built, so they resolve to `https://git.example.com/mirror/api/auth/*`. Any path accidentally included in the auth URLs is stripped. `BASE_URL` is read at runtime rather than baked in at build time, so the same prebuilt image works under any prefix.
## Example: fully declared deployment
A compose file that comes up ready to mirror, with no UI interaction required:
```yaml
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
container_name: gitea-mirror
restart: unless-stopped
ports:
- "4321:4321"
volumes:
- ./data:/app/data
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=4321
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BETTER_AUTH_URL=https://gitea-mirror.example.com
- PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
- BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
- ENCRYPTION_SECRET=${ENCRYPTION_SECRET}
- GITHUB_USERNAME=your-username
- GITHUB_TOKEN=${GITHUB_TOKEN}
- PRIVATE_REPOSITORIES=true
- MIRROR_STARRED=true
- GITEA_URL=https://gitea.example.com
- GITEA_USERNAME=admin
- GITEA_TOKEN=${GITEA_TOKEN}
- MIRROR_STRATEGY=preserve
- MIRROR_METADATA=true
- MIRROR_ISSUES=true
- MIRROR_RELEASES=true
- SCHEDULE_ENABLED=true
- SCHEDULE_INTERVAL=8h
- AUTO_IMPORT_REPOS=true
- AUTO_MIRROR_REPOS=true
```
Remember that the configuration is only written once an admin account exists. Sign up first, then restart the container, or simply let the next scheduled restart pick it up.
+396
View File
@@ -0,0 +1,396 @@
---
layout: '../../layouts/DocsLayout.astro'
title: Deployment
description: Install Gitea Mirror with Docker, Kubernetes, Nix, a Proxmox LXC container, or directly on a host with Bun.
---
# 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.
```yaml
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:
```bash
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
```
Then start it:
```bash
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:
```bash
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
```bash
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
```bash
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
```bash
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`:
```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 `appVersion` trails the application, so leaving `image.tag` empty pins an older image than you probably want. Set `image.tag` explicitly 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 `ReadWriteOnce` volume, 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.
```yaml
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
```bash
# 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`:
```text
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:
```nix
{
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:
```bash
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
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](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror) 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](https://bun.sh) runtime, version 1.2.9 or newer. There is no Node.js build.
```bash
# 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
```ini
[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:
```bash
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:
```bash
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:
```bash
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
```bash
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:
```json
{
"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.
@@ -0,0 +1,250 @@
---
layout: '../../layouts/DocsLayout.astro'
title: Environment Variables
description: Complete reference for every environment variable Gitea Mirror reads, grouped by what it controls.
---
# Environment Variables
This is the full reference. Most of these variables have an equivalent control in the web interface, and for a typical deployment you only need the handful in [Core](#core) and [Authentication and secrets](#authentication-and-secrets). See [Configuration](./configuration.md) for how the two layers relate.
## How environment configuration is applied
Variables fall into two groups.
**Process variables** are read directly by the running server: the host, port, base path, secrets, and auth URLs. They take effect immediately and have no UI equivalent.
**Seeded variables** are copied into the database. On startup, if any of `GITHUB_USERNAME`, `GITHUB_TOKEN`, `GITEA_URL`, `GITEA_USERNAME`, or `GITEA_TOKEN` is set, Gitea Mirror parses the whole environment configuration and writes it to the first user's configuration row. If no user account exists yet, nothing is written and it tries again on the next start.
> **Warning:** Seeded values are rewritten on every startup, so they win over changes you make in the UI. Manage a given setting either through the environment or through the UI, not both.
> **Note:** Boolean seeded variables are matched against the exact string `true`. Any other value, including an unset variable, is written as `false`, which means the schema defaults listed below only apply when the environment loader is not running. The one exception is `INCLUDE_COLLABORATOR_REPOS`, which distinguishes unset from `false`, and `AUTO_IMPORT_REPOS` and `SEQUENTIAL_ORG_CREATION`, which are matched against `false` instead so unset means enabled.
## Core
| Variable | Default | Description |
| --- | --- | --- |
| `NODE_ENV` | `development` | Runtime environment. The container image sets `production`. |
| `HOST` | `localhost` | Interface to bind. The container image sets `0.0.0.0`. |
| `PORT` | `4321` | Port to listen on. |
| `BASE_URL` | `/` | Path prefix for subpath deployments, for example `/mirror`. Applied at runtime, so one prebuilt image works under any prefix. |
| `DATABASE_URL` | `sqlite://data/gitea-mirror.db` | SQLite database location. The container image sets `file:data/gitea-mirror.db`. |
> **Note:** The SQLite file is always created at `data/gitea-mirror.db` relative to the process working directory, and the `data` directory is created if missing. `DATABASE_URL` is accepted but the driver does not currently resolve an alternative path from it. Relocate the database by mounting or symlinking the `data` directory.
## Authentication and secrets
| Variable | Default | Description |
| --- | --- | --- |
| `BETTER_AUTH_SECRET` | none | Signs session cookies. Required. Generate with `openssl rand -base64 32`. |
| `BETTER_AUTH_URL` | `http://localhost:4321` | Origin the auth server runs on. Scheme and host only, any path is stripped. |
| `PUBLIC_BETTER_AUTH_URL` | none | Origin the browser sends auth requests to. Set when the app is reachable at more than one origin. Scheme and host only. |
| `BETTER_AUTH_TRUSTED_ORIGINS` | none | Comma-separated additional origins allowed to make auth requests. The origin of `BETTER_AUTH_URL` is always trusted. |
| `BETTER_AUTH_LOG_LEVEL` | `warn` | Better Auth logger verbosity: `debug`, `info`, `warn`, or `error`. Use `debug` to trace SSO and OIDC sign-in problems. Better Auth does not read the `DEBUG` variable. |
| `ENCRYPTION_SECRET` | none | Key for AES-256-GCM encryption of stored tokens. Generate with `openssl rand -base64 48`. |
In Docker, `BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET` are generated on first boot if unset and saved to `/app/data/.better_auth_secret` and `/app/data/.encryption_secret` so they persist across restarts. Outside Docker there is no fallback: a missing `BETTER_AUTH_SECRET` logs a security warning in production and uses a known insecure development value.
### Header authentication
For deployments behind a reverse proxy that already authenticates users.
| Variable | Default | Description |
| --- | --- | --- |
| `HEADER_AUTH_ENABLED` | `false` | Enable header-based authentication. |
| `HEADER_AUTH_USER_HEADER` | `X-Authentik-Username` | Request header carrying the username. |
| `HEADER_AUTH_EMAIL_HEADER` | `X-Authentik-Email` | Request header carrying the email address. |
| `HEADER_AUTH_NAME_HEADER` | `X-Authentik-Name` | Request header carrying the display name. |
| `HEADER_AUTH_AUTO_PROVISION` | `false` | Create accounts automatically for unrecognized users. |
| `HEADER_AUTH_ALLOWED_DOMAINS` | none | Comma-separated email domains allowed to auto-provision. |
> **Warning:** Enable this only when a trusted proxy strips these headers from inbound client requests. Otherwise any client can set them and sign in as any user.
## GitHub
### Connection
| Variable | Default | Description |
| --- | --- | --- |
| `GITHUB_USERNAME` | none | Your GitHub username. |
| `GITHUB_TOKEN` | none | Personal access token. Needs `repo` for private repositories and `admin:org` to read organization data. |
| `GITHUB_TYPE` | `personal` | Account type: `personal` or `organization`. |
| `GH_API_URL` | `https://api.github.com` | GitHub API base URL. Point this at GitHub Enterprise Server (`https://ghe.example.com/api/v3`) or Enterprise Cloud with data residency (`https://api.TENANT.ghe.com`). |
| `GITHUB_API_URL` | none | Alternate name for `GH_API_URL`, checked second. Prefer `GH_API_URL`, since `GITHUB_API_URL` is set automatically inside GitHub Actions and can collide. |
Standard GitHub Enterprise Cloud on `github.com` needs no override. Use a token issued by the target Enterprise instance.
### Repository selection
| Variable | Default | Description |
| --- | --- | --- |
| `PRIVATE_REPOSITORIES` | `false` | Include private repositories. |
| `PUBLIC_REPOSITORIES` | `true` | Include public repositories. |
| `INCLUDE_ARCHIVED` | `false` | Include archived repositories. |
| `INCLUDE_COLLABORATOR_REPOS` | `true` | Include repositories where you are a collaborator rather than the owner. Set to `false` to limit imports to repositories you own. |
| `SKIP_FORKS` | `false` | Skip forked repositories. |
| `MIRROR_STARRED` | `false` | Mirror repositories you have starred. |
| `MIRROR_STARRED_LISTS` | none | Comma-separated GitHub Star List names to restrict starred mirroring to. Only used when `MIRROR_STARRED=true`. Empty means all starred repositories. |
| `STARRED_REPOS_ORG` | `starred` | Gitea organization that receives starred repositories. |
| `STARRED_REPOS_MODE` | `dedicated-org` | `dedicated-org` puts starred repositories in one organization, `preserve-owner` keeps the original owner path. |
| `STARRED_DUPLICATE_STRATEGY` | `suffix` | Name collision handling when two starred repositories share a name. `suffix` gives `repo-owner`, `prefix` gives `owner-repo`, `owner-org` places them in an owner-named organization. |
| `SKIP_STARRED_ISSUES` | `false` | Lightweight mode for starred repositories: mirror code but skip issues. |
| `AUTO_MIRROR_STARRED` | `false` | Mirror starred repositories during scheduled syncs and "Mirror All". When `false`, they are imported for browsing but must be mirrored individually. |
### Organizations
| Variable | Default | Description |
| --- | --- | --- |
| `MIRROR_ORGANIZATIONS` | `false` | Mirror repositories owned by GitHub organizations. |
| `INCLUDE_ORGANIZATIONS` | none | Comma-separated allowlist. When set, only these organizations are mirrored. Empty means all organizations you belong to. |
| `GITHUB_EXCLUDED_ORGS` | none | Comma-separated denylist. These organizations are skipped during discovery, matched case-insensitively. |
| `ONLY_MIRROR_ORGS` | `false` | Skip your personal repositories and mirror only organization repositories. |
| `PRESERVE_ORG_STRUCTURE` | `false` | Keep the GitHub organization structure in Gitea. Shorthand for the `preserve` strategy. |
| `MIRROR_STRATEGY` | `preserve` | Destination layout: `preserve`, `single-org`, `flat-user`, or `mixed`. Takes precedence over `PRESERVE_ORG_STRUCTURE`. |
## Gitea
### Connection
| Variable | Default | Description |
| --- | --- | --- |
| `GITEA_URL` | none | Gitea or Forgejo instance URL used for all API and mirroring traffic. |
| `GITEA_EXTERNAL_URL` | none | Browser-facing URL used for dashboard links, when it differs from `GITEA_URL`. |
| `GITEA_TOKEN` | none | Gitea access token. |
| `GITEA_USERNAME` | none | Gitea username, used as the default owner. |
| `GITEA_ORGANIZATION` | `github-mirrors` | Default destination organization for the `single-org` strategy. |
| `GITEA_SKIP_TLS_VERIFY` | `false` | Disable TLS certificate verification. Sets `NODE_TLS_REJECT_UNAUTHORIZED=0` process-wide. |
> **Warning:** `GITEA_SKIP_TLS_VERIFY=true` disables certificate checking for every outbound HTTPS request the process makes, not just to Gitea. Use [CA certificates](./ca-certificates.md) instead for self-signed or privately signed instances.
### Repository settings
| Variable | Default | Description |
| --- | --- | --- |
| `GITEA_ORG_VISIBILITY` | `public` | Visibility for organizations Gitea Mirror creates: `public`, `private`, `limited`, or `default`. |
| `GITEA_MIRROR_INTERVAL` | `8h` | How often Gitea itself re-pulls each mirror. Setting this also enables the scheduler. Accepts a duration string or seconds. |
| `GITEA_LFS` | `false` | Mirror Git LFS objects. Requires LFS enabled on the Gitea server. |
| `GITEA_CREATE_ORG` | `true` | Create destination organizations automatically when they do not exist. |
| `GITEA_PRESERVE_VISIBILITY` | `false` | Match each mirror's visibility to the source repository instead of using the organization default. |
| `GITEA_FORK_STRATEGY` | `reference` | How forks are handled: `skip`, `reference`, or `full-copy`. |
| `GITEA_ADD_TOPICS` | `true` | Copy repository topics to the mirror. |
| `GITEA_TOPIC_PREFIX` | none | Prefix applied to copied topics. |
| `GITEA_TEMPLATE_OWNER` | none | Owner of a Gitea template repository to apply to new mirrors. |
| `GITEA_TEMPLATE_REPO` | none | Name of that template repository. |
## Mirror content
| Variable | Default | Description |
| --- | --- | --- |
| `MIRROR_METADATA` | `false` | Master switch for metadata mirroring. Enabled implicitly if any of the four options below are `true`. |
| `MIRROR_ISSUES` | `false` | Mirror issues with their comments and labels. |
| `MIRROR_PULL_REQUESTS` | `false` | Mirror pull requests. Gitea's API cannot create pull requests, so they are created as issues tagged with a `pull-request` label. |
| `MIRROR_LABELS` | `false` | Mirror repository labels. |
| `MIRROR_MILESTONES` | `false` | Mirror milestones. |
| `MIRROR_RELEASES` | `false` | Mirror GitHub releases along with their assets. |
| `RELEASE_LIMIT` | `10` | Maximum releases mirrored per repository. |
| `MIRROR_WIKI` | `false` | Clone wiki content. |
| `MIRROR_ISSUE_CONCURRENCY` | `3` | Issues processed in parallel. |
| `MIRROR_PULL_REQUEST_CONCURRENCY` | `5` | Pull requests processed in parallel. |
> **Note:** Concurrency above `1` speeds up metadata mirroring but issues and pull requests may be created out of chronological order, since Gitea assigns numbers on creation. Set both to `1` if exact ordering matters more than throughput.
## Scheduling
| Variable | Default | Description |
| --- | --- | --- |
| `SCHEDULE_ENABLED` | `false` | Enable automatic mirroring. Also enabled implicitly whenever `SCHEDULE_INTERVAL`, `GITEA_MIRROR_INTERVAL`, or `DELAY` is set. |
| `SCHEDULE_INTERVAL` | `3600` | Interval between syncs. Accepts a duration string (`30m`, `8h`, `7d`), a five-field cron expression (`0 2 * * *`), or a plain number of seconds. |
| `DELAY` | none | Legacy alias for `SCHEDULE_INTERVAL`, in seconds. `SCHEDULE_INTERVAL` takes precedence. |
| `SCHEDULE_TIMEZONE` | `UTC` | Timezone used to evaluate cron expressions. |
| `SCHEDULE_CONCURRENT` | `false` | Allow a scheduled run to start while another is still in progress. |
| `SCHEDULE_BATCH_SIZE` | `10` | Repositories processed per batch. |
| `SCHEDULE_PAUSE_BETWEEN_BATCHES` | `5000` | Pause between batches, in milliseconds. |
| `SCHEDULE_TIMEOUT` | `3600000` | Maximum duration of one mirror operation, in milliseconds. |
| `SCHEDULE_LOG_LEVEL` | `info` | Scheduler log verbosity: `error`, `warn`, `info`, or `debug`. |
### Retries
| Variable | Default | Description |
| --- | --- | --- |
| `SCHEDULE_AUTO_RETRY` | `true` | Retry failed operations automatically. |
| `SCHEDULE_RETRY_ATTEMPTS` | `3` | Number of retry attempts. |
| `SCHEDULE_RETRY_DELAY` | `60000` | Delay between retries, in milliseconds. |
### Discovery and update detection
| Variable | Default | Description |
| --- | --- | --- |
| `AUTO_IMPORT_REPOS` | `true` | Discover and import newly created GitHub repositories during scheduled syncs. Set to `false` to disable. |
| `AUTO_MIRROR_REPOS` | `false` | Mirror newly imported repositories automatically, without a manual "Mirror All". |
| `SCHEDULE_ONLY_MIRROR_UPDATED` | `false` | Only mirror repositories that have changed since the last run. |
| `SCHEDULE_UPDATE_INTERVAL` | `86400000` | How often to check for updates, in milliseconds. |
| `SCHEDULE_SKIP_RECENTLY_MIRRORED` | `true` | Skip repositories mirrored very recently. |
| `SCHEDULE_RECENT_THRESHOLD` | `3600000` | What counts as recent, in milliseconds. |
### Maintenance and notifications
| Variable | Default | Description |
| --- | --- | --- |
| `SCHEDULE_CLEANUP_BEFORE_MIRROR` | `false` | Run repository cleanup before each mirroring pass. |
| `SCHEDULE_NOTIFY_ON_FAILURE` | `true` | Send a notification when a scheduled run fails. |
| `SCHEDULE_NOTIFY_ON_SUCCESS` | `false` | Send a notification when a scheduled run succeeds. |
Notification providers themselves (ntfy, Apprise, Gotify, and generic webhook) are configured on the Notifications tab of the Configuration page, not through environment variables.
## Cleanup
Cleanup covers both trimming the activity log and handling repositories that no longer exist on GitHub.
| Variable | Default | Description |
| --- | --- | --- |
| `CLEANUP_ENABLED` | `false` | Enable automatic cleanup. Also enabled implicitly when `CLEANUP_DELETE_IF_NOT_IN_GITHUB=true`. |
| `CLEANUP_RETENTION_DAYS` | `7` | How long activity events are kept, in days. |
| `CLEANUP_DELETE_IF_NOT_IN_GITHUB` | `true` | Act on repositories that no longer exist on GitHub. |
| `CLEANUP_ORPHANED_REPO_ACTION` | `archive` | What to do with an orphaned repository: `skip`, `archive`, or `delete`. |
| `CLEANUP_DELETE_FROM_GITEA` | `false` | Permit deletion of the repository from Gitea, not just from the local database. |
| `CLEANUP_PROTECTED_REPOS` | none | Comma-separated repository names cleanup will never touch. |
| `CLEANUP_DRY_RUN` | `false` | Log what cleanup would do without doing it. |
| `CLEANUP_BATCH_SIZE` | `10` | Items processed per batch. |
| `CLEANUP_PAUSE_BETWEEN_DELETES` | `2000` | Pause between deletions, in milliseconds. |
Cleanup has several safety behaviors worth knowing:
- If the GitHub API returns an error, cleanup is skipped for that run. A 404 or a rate limit never causes a mirror to be treated as orphaned.
- The `archive` action never deletes. Regular repositories use Gitea's native archive flag, which makes them read-only while preserving all data. Mirror repositories cannot be archived through the Gitea API, so they are renamed with an `archived-` prefix, given a description noting the preservation, and have their mirror interval pushed out to a year to stop sync attempts. They remain fully cloneable.
- If archiving fails for any reason, the repository is left alone and stays accessible.
## Advanced mirror behavior
These control lower-level mirroring mechanics and are rarely worth changing.
| Variable | Default | Description |
| --- | --- | --- |
| `NON_MIRROR_STRATEGY` | `delete` | What to do when the destination name in Gitea is taken by a repository that is not a mirror: `skip`, `delete`, or `rename`. |
| `ORG_CREATION_RETRIES` | `3` | Retry attempts when creating an organization. Must be between 1 and 10. |
| `ORG_CREATION_RETRY_DELAY` | `100` | Base delay for exponential backoff on organization creation, in milliseconds. Must be between 10 and 5000. |
| `SEQUENTIAL_ORG_CREATION` | `true` | Create organizations one at a time to avoid races. Set to `false` to parallelize. |
| `REPO_BATCH_SIZE` | `3` | Repositories processed in parallel during a sync. Must be between 1 and 50. |
| `SYNC_TIMEOUT` | `30000` | Timeout for a single sync operation, in milliseconds. Must be between 5000 and 300000. |
## TLS
| Variable | Default | Description |
| --- | --- | --- |
| `NODE_EXTRA_CA_CERTS` | none | Path to a PEM certificate or bundle to add to the trusted CA set. See [CA certificates](./ca-certificates.md). |
| `SERVER_CERT_PATH` | none | Path to a TLS certificate for direct HTTPS. |
| `SERVER_KEY_PATH` | none | Path to the matching private key. |
> **Warning:** `SERVER_CERT_PATH` and `SERVER_KEY_PATH` are read by the Astro Node standalone adapter, which only runs when you launch `dist/server/entry.mjs` directly. The default startup path, used by `bun run start` and by the Docker image, runs `scripts/runtime-server.ts` instead, which serves plain HTTP so it can apply `BASE_URL` path rewriting. In those setups these two variables have no effect and you should terminate TLS at a reverse proxy.
## Docker Compose substitution
These are not read by the application. They are substituted into the repository's `docker-compose.yml` when it resolves which image to pull or build.
| Variable | Default | Description |
| --- | --- | --- |
| `DOCKER_REGISTRY` | `ghcr.io` | Registry hostname. |
| `DOCKER_IMAGE` | `raylabshq/gitea-mirror` | Image repository. |
| `DOCKER_TAG` | `latest` | Image tag. |
| `PUID` | `1000` | User ID the container runs as. Used by `docker-compose.alt.yml`. |
| `PGID` | `1000` | Group ID the container runs as. Used by `docker-compose.alt.yml`. |
@@ -0,0 +1,187 @@
---
layout: '../../layouts/DocsLayout.astro'
title: Force-Push Protection
description: Detect rewritten history on GitHub before it overwrites your mirror, and snapshot or block the sync when it happens.
---
# Force-Push Protection
A GitHub repository can be force-pushed at any time, rewriting history, dropping commits, or deleting branches. When Gitea Mirror syncs a repository after that happens, the old history in Gitea is replaced with no way to get it back. Force-push protection notices the rewrite before the sync runs and either snapshots the current mirror or stops the sync entirely.
Configure it under **Configuration > Connections > Destructive Update Protection**.
## How detection works
Before each sync, Gitea Mirror compares branch tips on both sides.
1. **Collect branches.** Fetch branch names and their latest commit SHAs from Gitea and from GitHub. These are cheap API calls.
2. **Compare SHAs.** Branches whose SHAs match are unchanged and need no further work.
3. **Check ancestry.** For each branch whose SHA differs, ask GitHub's compare API how the two commits relate:
- The new commit descends from the old one, so this is an ordinary push and the sync is safe.
- The histories have diverged, so this is a force-push.
- GitHub returns 404 because the old commit no longer exists, which also means history was rewritten.
- The branch is gone from GitHub entirely, which is flagged as a destructive change.
Detection is fail-open. If the check itself cannot complete because of rate limits, network trouble, or an API outage, the sync proceeds as normal. A single branch that fails to check is skipped without affecting the others. Detection never blocks a sync because detection broke.
> **Note:** A rebase is a force-push. If you rebase branches routinely, expect detection to fire routinely, which is worth keeping in mind when choosing between Smart and Block & Approve.
## The four modes
| Mode | Stored value | What it does | Storage cost |
|---|---|---|---|
| Disabled | `disabled` | No detection, no snapshots | None |
| Always Backup | `always` | Snapshot before every sync | High |
| Smart | `on-force-push` | Detect first, snapshot only when a rewrite is found | Near zero in normal operation |
| Block & Approve | `block-on-force-push` | Detect first, halt the sync and wait for you | None |
Smart is the default.
### Disabled
Syncs run with no checks at all. If GitHub history is rewritten, the mirror silently follows and the old history is gone. Reasonable only for repositories you would not mind losing.
### Always Backup
Creates a snapshot before every sync whether or not anything was rewritten. This is the safest option and the most expensive one. A large mirror set generates a full bundle per repository per sync, so storage grows quickly.
### Smart
Runs detection before each sync. On an ordinary day nothing is rewritten, so no snapshot is created and there is no overhead beyond two lightweight branch listings. When a force-push is detected, a snapshot is taken and then the sync proceeds.
This is the recommended setting for most people: protection when it matters, close to free when it does not.
### Block & Approve
Runs detection and, when a rewrite is found, stops. The repository is marked `pending-approval` and is skipped by scheduled syncs until you decide what to do. Nothing is overwritten and nothing is snapshotted while it waits.
Use this for repositories where a destructive change should never land without a human looking at it first.
## Snapshot storage
Snapshots are [git bundles](https://git-scm.com/docs/git-bundle), single files containing the complete repository. Gitea Mirror creates one by making a mirror clone of the **Gitea** side, the state that is about to be overwritten, and bundling all refs from it.
Bundles are written to:
```text
<snapshot directory>/<user id>/<owner>/<repo>/<timestamp>.bundle
```
The default snapshot directory is `data/repo-backups` relative to the working directory, resolved to an absolute path before use. Path segments are sanitized, so characters outside letters, digits, dot, underscore, and hyphen become underscores.
### Restoring from a bundle
There is no restore button in the UI. A bundle is an ordinary git artifact, so you recover with git directly:
```bash
# Inspect what the bundle contains
git bundle list-heads 2026-08-03T14-22-31-004Z.bundle
# Clone it into a working directory
git clone 2026-08-03T14-22-31-004Z.bundle recovered-repo
# Push the recovered history back to Gitea
cd recovered-repo
git remote add gitea https://gitea.example.com/owner/repo.git
git push --mirror gitea
```
> **Warning:** `git push --mirror` replaces the remote's refs with what you have locally. Confirm the bundle holds the history you want before pushing it over a live repository.
## Retention settings
These appear once you pick any mode other than Disabled.
### Snapshot retention count
How many bundles to keep per repository. Once the count is exceeded, the oldest bundles are deleted. Minimum 1, default **5**.
### Snapshot retention days
Maximum age for a bundle. Anything older is deleted when retention runs. Set it to `0` to turn off age-based pruning. Default **30**.
At least one bundle is always kept, even when every bundle is older than the retention window. You never end up with an empty snapshot directory just because a repository went quiet.
### Snapshot directory
Where bundles are stored. Default `data/repo-backups`.
> **Note:** In Docker, keep this inside `/app/data` so snapshots land on your persistent volume. A path outside it disappears when the container is replaced.
### Block sync on snapshot failure
Available for Always Backup and Smart. When enabled, a snapshot that fails, because the disk is full or permissions are wrong, also stops the sync. When disabled, the sync runs anyway and you get an unprotected overwrite.
Default is enabled, and leaving it enabled is the right call if you actually rely on these snapshots.
Retention is enforced right after a new bundle is written, so pruning only happens for repositories that are actively being snapshotted.
## Approving a blocked repository
In Block & Approve mode, a detected force-push leaves the repository with status `pending-approval`. Scheduled syncs skip it until you act. The repository table shows a badge along with **Approve** and **Dismiss** buttons.
- **Approve** creates a snapshot first, then runs the sync. The rewritten history lands in Gitea and the previous state is preserved in a bundle.
- **Dismiss** clears the flag and resumes normal syncing without creating a snapshot. Use this when you already know the rewrite is fine.
Both actions are also available through the API:
```bash
# Approve: snapshot, then sync
curl -X POST https://your-domain.com/api/job/approve-sync \
-H "Content-Type: application/json" \
-H "Cookie: <session cookie>" \
-d '{"repositoryIds": ["<repository-id>"], "action": "approve"}'
# Dismiss: clear the block without a snapshot
curl -X POST https://your-domain.com/api/job/approve-sync \
-H "Content-Type: application/json" \
-H "Cookie: <session cookie>" \
-d '{"repositoryIds": ["<repository-id>"], "action": "dismiss"}'
```
Both accept multiple repository IDs in one call.
## Environment variables
The mode is normally set in the UI and stored in the database. Environment variables act as a fallback for instances that have never had a strategy saved, which makes them useful for provisioning a new deployment.
| Variable | Purpose | Default |
|---|---|---|
| `PRE_SYNC_BACKUP_STRATEGY` | Initial strategy: `disabled`, `always`, `on-force-push`, or `block-on-force-push` | unset |
| `PRE_SYNC_BACKUP_ENABLED` | Legacy switch. `true` maps to `always`, `false` maps to `disabled` | unset |
| `PRE_SYNC_BACKUP_DIR` | Snapshot directory when the config field is empty | `data/repo-backups` |
| `PRE_SYNC_BACKUP_KEEP_COUNT` | Retention count when the config field is unset | `5` |
| `PRE_SYNC_BACKUP_RETENTION_DAYS` | Retention days when the config field is unset | `30` |
| `PRE_SYNC_BACKUP_GIT_USERNAME` | Username paired with the Gitea token when cloning for a snapshot | `oauth2` |
> **Note:** Saved configuration always wins over the environment. Once a strategy has been chosen in the UI, changing `PRE_SYNC_BACKUP_STRATEGY` has no effect, so change the mode in the UI instead of wondering why the variable is ignored.
The strategy is resolved in this order, stopping at the first match:
1. The `backupStrategy` field in the saved configuration.
2. The deprecated `backupBeforeSync` boolean, where `true` maps to Smart and `false` maps to Disabled.
3. `PRE_SYNC_BACKUP_STRATEGY`.
4. `PRE_SYNC_BACKUP_ENABLED`.
5. The default, Smart.
## Upgrading from backupBeforeSync
The older `backupBeforeSync` boolean still works and is mapped automatically:
| Old setting | Current equivalent |
|---|---|
| `backupBeforeSync: true` | Smart (`on-force-push`) |
| `backupBeforeSync: false` | Disabled |
| Neither set | Smart (`on-force-push`) |
Note that `true` maps to Smart rather than Always Backup. Older configurations defaulted this field to true, so mapping it to Always Backup would have quietly turned on full bundles before every sync for everyone who upgraded.
## Troubleshooting
**A repository is stuck in pending-approval.** That is the mode working as designed. Use Approve or Dismiss in the repository table, or call the approve-sync endpoint.
**Detection is always skipped.** Check the activity log for the reason. Common causes are a repository that has not been mirrored to Gitea yet, so there is nothing to compare, along with GitHub rate limits and network errors. All of these fail open by design.
**Snapshots are eating disk.** Lower the retention count or the retention days, and switch from Always Backup to Smart if you are not already on it. Smart only writes a bundle when history is actually rewritten.
**Snapshots fail with a git error.** The Gitea token needs read access to the repository being cloned. Token values are masked in error messages, so check the token's scopes in Gitea rather than looking for it in the log.
+54
View File
@@ -0,0 +1,54 @@
---
import DocsLayout from '../../layouts/DocsLayout.astro';
import { docsNav } from '../../lib/docs-nav';
const frontmatter = {
title: 'Documentation',
description:
'Everything you need to install, configure and run Gitea Mirror: quickstart, deployment, configuration, notifications, authentication and more.',
};
const sectionBlurbs: Record<string, string> = {
'Getting Started': 'Install Gitea Mirror and get your first mirror running.',
'Configuration': 'Connect GitHub and Gitea, tune mirroring, and pre-configure with environment variables.',
'Features': 'Notifications, single sign-on, and protection against destructive updates.',
'Reference': 'How it all fits together, plus advanced operations.',
};
---
<DocsLayout frontmatter={frontmatter}>
<h1>Gitea Mirror Documentation</h1>
<p>
Gitea Mirror is a self-hosted service that automatically mirrors your GitHub
repositories to a Gitea instance: code, releases, issues, wikis and more.
These docs cover installation, configuration and every feature in the app.
</p>
{docsNav.map((section) => (
<section>
<h2>{section.title}</h2>
<p>{sectionBlurbs[section.title]}</p>
<ul>
{section.items.map((item) => (
<li>
<a href={item.href}>{item.title}</a>
</li>
))}
</ul>
</section>
))}
<h2>Offline reference</h2>
<p>
Running air-gapped? The same information ships as plain markdown in the
repository under
<a
href="https://github.com/RayLabsHQ/gitea-mirror/tree/main/docs"
target="_blank"
rel="noopener noreferrer"
>
the docs directory
</a>
, versioned with the release you installed.
</p>
</DocsLayout>
+278
View File
@@ -0,0 +1,278 @@
---
layout: '../../layouts/DocsLayout.astro'
title: Notifications
description: Send mirror job alerts to ntfy, Apprise, Gotify, or your own webhook endpoint.
---
# Notifications
Gitea Mirror can push an alert whenever a mirror job finishes, so you find out about a broken sync without opening the dashboard. Four providers are supported: ntfy, Apprise, Gotify, and a plain HTTP webhook. You pick one provider at a time.
All notification settings live in the web UI under **Configuration > Notifications**. There are no environment variables for notifications, the configuration is stored per user in the database.
## Enabling notifications
1. Open **Configuration > Notifications**.
2. Turn on the **Notifications** switch.
3. Pick a provider from the segmented control: Ntfy.sh, Apprise, Gotify, or Webhook.
4. Fill in the provider fields described below.
5. Click **Send test** to confirm the setup works.
Settings save automatically as you edit them.
## Event types
The **Notification Events** card controls which job outcomes produce a notification.
| Event | Fires when | Default |
|---|---|---|
| Sync errors | A mirror job ends with status `failed` | On |
| Sync success | A mirror job ends with status `mirrored` or `synced` | Off |
| New repository discovered | Not yet implemented, the switch is disabled and marked SOON | Off |
Only terminal job statuses trigger notifications. Jobs that are still queued or in progress produce nothing.
Errors are always escalated by the provider that supports priorities, regardless of the default priority you configure. For ntfy that means `high`, for Gotify it means priority `8`.
## Ntfy
[ntfy](https://ntfy.sh) is a lightweight HTTP pub-sub service. Use the public server at `https://ntfy.sh` or run your own.
### Settings
| Field | Required | Notes |
|---|---|---|
| Server URL | No | Defaults to `https://ntfy.sh`. Point it at your own instance if self-hosting. |
| Topic | Yes | The topic your clients subscribe to. |
| Access token | No | Needed only if your ntfy server requires authentication. Sent as `Authorization: Bearer <token>`. |
| Default priority | No | One of `min`, `low`, `default`, `high`, `urgent`. Defaults to `default`. |
> **Warning:** On the public ntfy server anyone who knows your topic name can read your notifications. Pick a long, unguessable topic such as `gitea-mirror-7f3a91c2b8`, or self-host with authentication.
### What gets sent
Gitea Mirror sends a plain text POST to `<server url>/<topic>`. The message body is the notification text, and the metadata travels in headers:
```http
POST /gitea-mirror HTTP/1.1
Host: ntfy.sh
Title: Mirror Failed: acme/website
Priority: high
Tags: warning
Authorization: Bearer tk_...
Repository acme/website failed to mirror
Details: remote: repository not found
```
Successful jobs use the `white_check_mark` tag and your configured default priority. Failures use the `warning` tag and `high` priority.
### Self-hosting quick start
```bash
docker run -p 8080:80 binwiederhier/ntfy serve
```
Then set the Server URL to `http://ntfy:8080` if Gitea Mirror runs in the same Docker network, or to the host address otherwise.
## Apprise
[Apprise](https://github.com/caronc/apprise-api) is an aggregator that fans a single notification out to more than 100 services, including Slack, Discord, Telegram, email, and Pushover. Run the Apprise API server, configure your destinations there, and point Gitea Mirror at it.
### Settings
| Field | Required | Notes |
|---|---|---|
| Server URL | Yes | Base URL of your Apprise API server, for example `http://apprise:8000`. |
| Token / path | Yes | The Apprise configuration key you created. |
| Tag filter | No | Restricts delivery to Apprise services carrying this tag. Leave blank to notify everything in that configuration. |
### Running the Apprise API server
```yaml
services:
apprise:
image: caronc/apprise:latest
ports:
- "8000:8000"
volumes:
- apprise-config:/config
volumes:
apprise-config:
```
Add your notification URLs through the Apprise web UI at `http://localhost:8000`, save them under a configuration key, then use that key as the token in Gitea Mirror.
### What gets sent
A JSON POST to `<server url>/notify/<token>`:
```json
{
"title": "Mirror Success: acme/website",
"body": "Repository acme/website mirrored successfully",
"type": "success",
"tag": "homelab"
}
```
The `type` field is `failure` for sync errors and `success` otherwise, which is what Apprise uses to colour the message. The `tag` field is omitted when you leave the tag filter empty.
## Gotify
[Gotify](https://gotify.net) is a self-hosted push server with its own Android client. Create an application inside Gotify, then paste its token here.
### Settings
| Field | Required | Notes |
|---|---|---|
| Server URL | Yes | Base URL of your Gotify server. |
| Application token | Yes | Created under Apps in the Gotify UI. Sent as the `X-Gotify-Key` header. |
| Default priority | No | Integer from 0 to 10, defaults to 5. Values outside that range are clamped. |
> **Note:** Use an application token, not a client token. Client tokens can read messages but cannot post them, and Gotify will reject the request.
### What gets sent
A JSON POST to `<server url>/message`:
```json
{
"title": "Mirror Failed: acme/website",
"message": "Repository acme/website failed to mirror\nDetails: remote: repository not found",
"priority": 8
}
```
Failures always use priority 8. Everything else uses your configured default. Gotify's Android client only vibrates and pops a heads-up notification above priority 4, which is why the error priority is fixed higher than the default.
## Webhook
The webhook provider posts JSON to any URL you control. Use it to drive a chat bot, an incident tool, a home automation flow, or anything else that can accept an HTTP request.
### Settings
| Field | Required | Notes |
|---|---|---|
| Webhook URL | Yes | The full URL to POST to. |
| Signing secret | No | When set, requests carry an HMAC signature header so your receiver can verify authenticity. |
### Payload
Every notification is a `POST` with `Content-Type: application/json` and this body:
```json
{
"title": "Mirror Failed: acme/website",
"message": "Repository acme/website failed to mirror\nDetails: remote: repository not found",
"type": "sync_error",
"timestamp": "2026-08-03T14:22:31.004Z"
}
```
| Field | Type | Description |
|---|---|---|
| `title` | string | Short summary line, includes the repository or organization name. |
| `message` | string | Full text, may contain newlines and a `Details:` section. |
| `type` | string | `sync_error` or `sync_success`. |
| `timestamp` | string | ISO 8601 timestamp generated when the request is built. |
### Verifying the signature
If you set a signing secret, Gitea Mirror adds a header:
```http
X-Webhook-Signature: sha256=<hex digest>
```
The digest is an HMAC-SHA256 of the exact request body, keyed with your secret, hex encoded and prefixed with `sha256=`.
> **Warning:** Verify against the raw request body, not a re-serialized object. Most JSON frameworks reorder keys or change whitespace when they parse and re-encode, which produces a different digest and makes every request look invalid.
Node with Express:
```javascript
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.WEBHOOK_SECRET;
const app = express();
// express.raw keeps the untouched bytes so the digest matches
app.post("/hooks/gitea-mirror", express.raw({ type: "application/json" }), (req, res) => {
const received = req.get("X-Webhook-Signature") ?? "";
const expected = "sha256=" + createHmac("sha256", secret).update(req.body).digest("hex");
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.type, event.title, event.timestamp);
res.sendStatus(204);
});
app.listen(3000);
```
Python with Flask:
```python
import hashlib
import hmac
import os
from flask import Flask, abort, request
SECRET = os.environ["WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/hooks/gitea-mirror")
def receive():
raw = request.get_data() # bytes, exactly as received
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-Webhook-Signature", "")
if not hmac.compare_digest(expected, received):
abort(401)
event = request.get_json()
print(event["type"], event["title"], event["timestamp"])
return "", 204
```
Both examples use a constant time comparison. A plain `==` leaks timing information that can help an attacker forge a signature byte by byte.
## Testing your configuration
The **Send test** button appears in the Notifications card footer once notifications are enabled. It sends a message titled "Gitea Mirror - Test Notification" through the provider currently shown in the form, using the values on screen rather than the last saved ones. The test is typed as a success event, so it uses your default priority rather than the escalated error priority.
A toast reports the outcome. Failures show a sanitized reason such as "Ntfy topic is required" or an upstream status like unauthorized or not found. Detailed errors go to the server log under the `[NotificationService]` prefix.
## Security
Notification credentials are encrypted at rest with AES-256-GCM, the same scheme used for GitHub and Gitea tokens. This covers the ntfy access token, the Apprise token, the Gotify application token, and the webhook signing secret.
Only the active provider's credential is decrypted when a notification fires. A stale or corrupted credential left behind on a provider you no longer use will not break delivery through the provider you do use.
Notification delivery never breaks a mirror job. Failures are caught and logged, and the job result is unaffected.
## Troubleshooting
**Nothing arrives at all.** Check that the Notifications switch is on and that the event type you expect is enabled. Sync success is off by default, so a healthy instance is silent unless you turn it on.
**The test works but real jobs are quiet.** The test always sends, real notifications are filtered by the event switches. Confirm that **Sync errors** or **Sync success** matches the job outcome you are waiting on.
**ntfy returns 401 or 403.** Your server requires authentication. Add an access token, and make sure it grants publish rights on that specific topic.
**Apprise connection refused.** Confirm the Apprise container is reachable from the Gitea Mirror container. Inside Docker they need to share a network, and the URL should use the service name rather than `localhost`.
**Gotify returns 401.** The token is either a client token instead of an application token, or it belongs to a deleted application.
**Webhook signatures never match.** Your receiver is almost certainly hashing a re-serialized body. Capture the raw bytes before any JSON parsing happens.
For anything else, check the server logs for lines beginning with `[NotificationService]`.
+170
View File
@@ -0,0 +1,170 @@
---
layout: '../../layouts/DocsLayout.astro'
title: Quick Start
description: Get Gitea Mirror running with Docker in a few minutes, or build it from source or run it directly with Bun.
---
# Quick Start
![The Gitea Mirror dashboard](/assets/dashboard.png)
Gitea Mirror ships as a prebuilt multi-architecture container image. The fastest path is to pull that image and configure everything else through the web interface after it starts. This page covers the Docker path first, then building from source, then running directly on Bun.
## Requirements
Before you start, you need:
- A GitHub personal access token with the `repo` and `admin:org` scopes.
- A Gitea or Forgejo instance you can reach from wherever Gitea Mirror runs, plus an access token for it.
- Docker with the Compose plugin, or Bun 1.2.9 or newer for a bare metal install.
You do not need any of the GitHub or Gitea details at container start time. Gitea Mirror comes up with an empty configuration and you fill it in through the Configuration page.
## Docker with the prebuilt image
This is the recommended setup. It uses `ghcr.io/raylabshq/gitea-mirror:latest` and stores the SQLite database in a `./data` directory next to your compose file.
Create a `docker-compose.yml`:
```yaml
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
container_name: gitea-mirror
restart: unless-stopped
ports:
- "${PORT:-4321}:4321"
user: ${PUID:-1000}:${PGID:-1000}
volumes:
- ./data:/app/data
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=4321
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:4321}
```
Then start it:
```bash
docker compose up -d
```
Open `http://localhost:4321` and create an account. The first account to sign up becomes the admin.
> **Note:** `PUID` and `PGID` control the user the container runs as. They must match the owner of the `./data` directory on the host, otherwise the container cannot create the database file. On most Linux systems the first non-root user is `1000:1000`, which is the default.
### Using the compose files in the repository
If you clone the repository you get two ready-made compose files. `docker-compose.alt.yml` is the minimal one, matching the example above:
```bash
git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror
docker compose -f docker-compose.alt.yml up -d
```
`docker-compose.yml` is the full version. It builds from source by default and exposes every supported environment variable, which is useful when you want the whole configuration declared in one file rather than entered through the UI.
### Secrets
`BETTER_AUTH_SECRET` signs session cookies and `ENCRYPTION_SECRET` encrypts your GitHub and Gitea tokens at rest. If you leave either unset, the container entrypoint generates a random value on first boot and saves it inside the data volume, at `/app/data/.better_auth_secret` and `/app/data/.encryption_secret` respectively. That means restarts keep working, but the secrets only survive as long as the volume does.
For anything beyond a quick trial, set them explicitly so they are backed up with the rest of your configuration:
```bash
openssl rand -base64 32 # BETTER_AUTH_SECRET
openssl rand -base64 48 # ENCRYPTION_SECRET
```
> **Warning:** If you lose `ENCRYPTION_SECRET`, the stored GitHub and Gitea tokens can no longer be decrypted and you will have to re-enter them.
### Running behind a reverse proxy
When you access Gitea Mirror through a proxy, the auth layer needs to know its public origin. Set all three of these to the external URL:
```yaml
environment:
- BETTER_AUTH_URL=https://gitea-mirror.example.com
- PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
- BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
```
These must be origins only, meaning scheme and host with no path. If you serve the app under a path prefix such as `https://git.example.com/mirror`, put the prefix in `BASE_URL=/mirror` and keep the three auth variables as the bare origin. See the [configuration guide](./configuration.md) for the full explanation.
## Build from source with Docker
If you want to build the image yourself, for example to pin a commit or add your own patches, clone the repository and use the included `Dockerfile`:
```bash
git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror
docker compose up -d --build
```
The repository's `docker-compose.yml` already declares a `build` section pointing at the `Dockerfile`, with `linux/amd64` and `linux/arm64` as target platforms. The build is multi-stage: it installs dependencies and runs the Astro build, compiles a static `git-lfs` binary, then copies both into a slim runtime layer based on the official Bun image.
## Bare metal with Bun
Running directly on the host is useful for development and for environments where you would rather not use containers.
Install Bun 1.2.9 or newer:
```bash
curl -fsSL https://bun.sh/install | bash
```
Then clone, install dependencies, and initialize the database:
```bash
git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror
bun run setup
```
`bun run setup` runs `bun install` followed by the database initializer. To start a development server with hot reload:
```bash
bun run dev
```
For a production run, build first and then start the runtime server:
```bash
bun run build
bun run start
```
The dev server and the production server both listen on port 4321 by default. `bun run start` runs `scripts/runtime-server.ts`, which reads `HOST`, `PORT`, and `BASE_URL` from the environment.
> **Note:** Outside Docker there is no entrypoint script generating secrets for you. Set `BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET` in your environment or in a `.env` file before starting, otherwise sessions and token encryption fall back to insecure development defaults.
## First run
Whichever install path you took, the first-run flow is the same:
1. Open the app at `http://localhost:4321`, or at whatever URL you configured.
2. Sign up. The first account created becomes the admin account.
3. Go to the Configuration page and fill in the **GitHub Connection** and **Gitea Connection** cards on the Connections tab. Use the test buttons to confirm both sides authenticate.
4. Choose a mirror strategy, which decides where repositories land in Gitea.
5. Import your GitHub repositories, then mirror them.
6. Optionally, turn on scheduled syncing on the Automation tab so new commits and new repositories are picked up without manual action.
## Verifying the deployment
Gitea Mirror exposes a health endpoint at `/api/health` that reports application status, database connectivity, and the state of the job recovery system:
```bash
curl http://localhost:4321/api/health
```
The container image also has a built-in `HEALTHCHECK` that polls this endpoint, so `docker ps` shows the container as healthy or unhealthy without any extra work on your side. See [Advanced topics](./advanced.md) for the full response shape.
## Where to go next
- [Configuration](./configuration.md) covers the Configuration page tab by tab, plus environment-based pre-configuration.
- [Environment variables](./environment-variables.md) is the complete reference.
- [CA certificates](./ca-certificates.md) explains how to trust a self-signed or privately signed Gitea instance.