mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 19:46:44 +03:00
Compare commits
1 Commits
main
...
security-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcc92b8f27 |
24
Dockerfile
24
Dockerfile
@@ -1,10 +1,8 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM oven/bun:1.3.3-debian AS base
|
||||
FROM oven/bun:1.2.23-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl ca-certificates
|
||||
|
||||
# ----------------------------
|
||||
FROM base AS deps
|
||||
@@ -17,9 +15,9 @@ FROM deps AS builder
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
RUN mkdir -p dist/scripts && \
|
||||
for script in scripts/*.ts; do \
|
||||
bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \
|
||||
done
|
||||
for script in scripts/*.ts; do \
|
||||
bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \
|
||||
done
|
||||
|
||||
# ----------------------------
|
||||
FROM deps AS pruner
|
||||
@@ -42,12 +40,12 @@ ENV DATABASE_URL=file:data/gitea-mirror.db
|
||||
|
||||
# Create directories and setup permissions
|
||||
RUN mkdir -p /app/certs && \
|
||||
chmod +x ./docker-entrypoint.sh && \
|
||||
mkdir -p /app/data && \
|
||||
addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 gitea-mirror && \
|
||||
chown -R gitea-mirror:nodejs /app/data && \
|
||||
chown -R gitea-mirror:nodejs /app/certs
|
||||
chmod +x ./docker-entrypoint.sh && \
|
||||
mkdir -p /app/data && \
|
||||
addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 gitea-mirror && \
|
||||
chown -R gitea-mirror:nodejs /app/data && \
|
||||
chown -R gitea-mirror:nodejs /app/certs
|
||||
|
||||
USER gitea-mirror
|
||||
|
||||
|
||||
496
Security-Audit.md
Normal file
496
Security-Audit.md
Normal file
@@ -0,0 +1,496 @@
|
||||
Date: 2025-11-07Application: Gitea Mirror v3.9.0Branch: bun-v1.3.1Scope: Full application security review
|
||||
|
||||
---
|
||||
Executive Summary
|
||||
|
||||
A comprehensive security audit was conducted across all components of the Gitea Mirror application, including authentication, authorization,
|
||||
encryption, input validation, API endpoints, database operations, UI components, and external integrations. The review identified 23 security
|
||||
findings across multiple severity levels.
|
||||
|
||||
Critical Findings: 2
|
||||
|
||||
High Severity: 9
|
||||
|
||||
Medium Severity: 10
|
||||
|
||||
Low Severity: 2
|
||||
|
||||
Overall Security Posture: The application demonstrates strong foundational security practices (AES-256-GCM encryption, Drizzle ORM parameterized
|
||||
queries, React auto-escaping) but has critical authorization flaws that require immediate remediation.
|
||||
|
||||
---
|
||||
CRITICAL SEVERITY VULNERABILITIES
|
||||
|
||||
Vuln 1: Broken Object Level Authorization (BOLA) - Multiple Endpoints
|
||||
|
||||
Severity: CRITICALConfidence: 1.0Category: Authorization Bypass
|
||||
|
||||
Affected Files:
|
||||
- src/pages/api/config/index.ts:18
|
||||
- src/pages/api/job/mirror-repo.ts:16
|
||||
- src/pages/api/github/repositories.ts:11
|
||||
- src/pages/api/dashboard/index.ts:9
|
||||
- src/pages/api/activities/index.ts:8
|
||||
- src/pages/api/sync/repository.ts:15
|
||||
- src/pages/api/sync/organization.ts:14
|
||||
- src/pages/api/job/mirror-org.ts:14
|
||||
- src/pages/api/job/retry-repo.ts:18
|
||||
- src/pages/api/job/sync-repo.ts:11
|
||||
- src/pages/api/job/schedule-sync-repo.ts:13
|
||||
|
||||
Description: Multiple API endpoints accept a userId parameter from client requests without validating that the authenticated user matches the
|
||||
requested userId. This allows any authenticated user to access and manipulate any other user's data.
|
||||
|
||||
Exploit Scenario:
|
||||
POST /api/config/index
|
||||
Content-Type: application/json
|
||||
Cookie: better-auth-session=attacker-session
|
||||
|
||||
{
|
||||
"userId": "victim-user-id",
|
||||
"giteaConfig": {
|
||||
"token": "attacker-controlled-token"
|
||||
}
|
||||
}
|
||||
|
||||
Result: Attacker modifies victim's configuration, steals encrypted tokens, triggers mirror operations, or accesses activity logs.
|
||||
|
||||
Recommendation:
|
||||
export const POST: APIRoute = async (context) => {
|
||||
// 1. Validate authentication
|
||||
const { user, response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
// 2. Use authenticated userId from session, NOT from request body
|
||||
const authenticatedUserId = user!.id;
|
||||
|
||||
// 3. Don't accept userId from client
|
||||
const body = await request.json();
|
||||
const { githubConfig, giteaConfig } = body;
|
||||
|
||||
// 4. Use session userId for all operations
|
||||
const config = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, authenticatedUserId));
|
||||
}
|
||||
|
||||
---
|
||||
Vuln 2: Header Authentication Spoofing - src/lib/auth-header.ts:48
|
||||
|
||||
Severity: CRITICALConfidence: 0.95Category: Authentication Bypass
|
||||
|
||||
Description: The header authentication mechanism trusts HTTP headers (X-Authentik-Username, X-Authentik-Email) without proper validation. If the
|
||||
application is accessible without a properly configured reverse proxy, attackers can send these headers directly to impersonate any user.
|
||||
|
||||
Exploit Scenario:
|
||||
GET /api/config/index?userId=admin
|
||||
Host: gitea-mirror.example.com
|
||||
X-Authentik-Username: admin
|
||||
X-Authentik-Email: admin@example.com
|
||||
|
||||
Result: Attacker gains admin access without authentication.
|
||||
|
||||
Recommendation:
|
||||
// 1. Add trusted proxy IP validation
|
||||
const TRUSTED_PROXY_IPS = process.env.TRUSTED_PROXY_IPS?.split(',') || [];
|
||||
|
||||
export function extractUserFromHeaders(headers: Headers, remoteIp: string): UserInfo | null {
|
||||
// Validate request comes from trusted reverse proxy
|
||||
if (TRUSTED_PROXY_IPS.length > 0 && !TRUSTED_PROXY_IPS.includes(remoteIp)) {
|
||||
console.warn(`Header auth rejected: untrusted IP ${remoteIp}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Add shared secret validation
|
||||
const authSecret = headers.get('X-Auth-Secret');
|
||||
if (authSecret !== process.env.HEADER_AUTH_SECRET) {
|
||||
console.warn('Header auth rejected: invalid secret');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Continue with header extraction...
|
||||
}
|
||||
|
||||
Additional Requirements:
|
||||
- Document that header auth MUST only be used behind a reverse proxy
|
||||
- Reverse proxy MUST strip X-Authentik-* headers from untrusted requests
|
||||
- Direct access MUST be blocked via firewall rules
|
||||
|
||||
---
|
||||
HIGH SEVERITY VULNERABILITIES
|
||||
|
||||
Vuln 3: Missing Authentication on Critical Endpoints
|
||||
|
||||
Severity: HIGHConfidence: 1.0Category: Authentication Bypass
|
||||
|
||||
Affected Endpoints:
|
||||
- /api/github/test-connection - Tests GitHub connection with user's token
|
||||
- /api/gitea/test-connection - Tests Gitea connection with user's token
|
||||
- /api/rate-limit/index - Exposes rate limit info
|
||||
- /api/events/index - SSE endpoint for events
|
||||
- /api/activities/cleanup - Deletes activities
|
||||
|
||||
Description: Several sensitive endpoints lack authentication checks entirely.
|
||||
|
||||
Exploit Scenario:
|
||||
POST /api/github/test-connection
|
||||
{"token": "stolen-token", "userId": "victim"}
|
||||
|
||||
Recommendation: Add requireAuth to all sensitive endpoints.
|
||||
|
||||
---
|
||||
Vuln 4: Token Exposure Through Config API - src/pages/api/config/index.ts:220
|
||||
|
||||
Severity: HIGHConfidence: 0.9Category: Sensitive Data Exposure
|
||||
|
||||
Description: The GET /api/config/index endpoint decrypts and returns GitHub/Gitea API tokens in plaintext. Combined with BOLA, any user can retrieve
|
||||
any other user's tokens.
|
||||
|
||||
Exploit Scenario:
|
||||
1. Attacker authenticates as User A
|
||||
2. Calls /api/config/index?userId=admin
|
||||
3. Receives admin's decrypted GitHub Personal Access Token
|
||||
4. Uses stolen token to access admin's repositories
|
||||
|
||||
Recommendation:
|
||||
// Never send full tokens - use masked versions
|
||||
if (githubConfig.token) {
|
||||
const decrypted = decrypt(githubConfig.token);
|
||||
githubConfig.token = maskToken(decrypted); // "ghp_****...last4"
|
||||
githubConfig.tokenSet = true;
|
||||
}
|
||||
|
||||
---
|
||||
Vuln 5: Static Salt in Key Derivation - src/lib/utils/encryption.ts:21
|
||||
|
||||
Severity: HIGHConfidence: 0.95Category: Cryptographic Weakness
|
||||
|
||||
Description: The key derivation function uses a static, hardcoded salt that reduces protection against precomputation attacks.
|
||||
|
||||
Exploit Scenario: If ENCRYPTION_SECRET is weak or compromised, the static salt makes brute-force attacks more efficient. Attackers can build targeted
|
||||
rainbow tables.
|
||||
|
||||
Recommendation:
|
||||
// Generate and store unique salt per installation
|
||||
const SALT_FILE = '/app/data/.encryption_salt';
|
||||
let installationSalt: Buffer;
|
||||
|
||||
if (fs.existsSync(SALT_FILE)) {
|
||||
installationSalt = Buffer.from(fs.readFileSync(SALT_FILE, 'utf8'), 'hex');
|
||||
} else {
|
||||
installationSalt = crypto.randomBytes(32);
|
||||
fs.writeFileSync(SALT_FILE, installationSalt.toString('hex'));
|
||||
fs.chmodSync(SALT_FILE, 0o600);
|
||||
}
|
||||
|
||||
return crypto.pbkdf2Sync(secret, installationSalt, ITERATIONS, KEY_LENGTH, 'sha256');
|
||||
|
||||
---
|
||||
Vuln 6: Weak Default Secret - src/lib/config.ts:22
|
||||
|
||||
Severity: HIGHConfidence: 0.90Category: Hardcoded Credentials
|
||||
|
||||
Description: The application uses a hardcoded default for BETTER_AUTH_SECRET when not set.
|
||||
|
||||
Exploit Scenario: Non-Docker deployments may use the weak default. Attackers can forge authentication tokens using the known default secret.
|
||||
|
||||
Recommendation:
|
||||
BETTER_AUTH_SECRET: (() => {
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
const weakDefaults = ["your-secret-key-change-this-in-production"];
|
||||
|
||||
if (!secret || weakDefaults.includes(secret)) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error("BETTER_AUTH_SECRET required. Generate with: openssl rand -base64 32");
|
||||
}
|
||||
return "dev-secret-minimum-32-chars";
|
||||
}
|
||||
|
||||
if (secret.length < 32) {
|
||||
throw new Error("BETTER_AUTH_SECRET must be at least 32 characters");
|
||||
}
|
||||
|
||||
return secret;
|
||||
})()
|
||||
|
||||
---
|
||||
Vuln 7: Unencrypted OAuth Client Secrets - src/lib/db/schema.ts:569
|
||||
|
||||
Severity: HIGHConfidence: 0.92Category: Sensitive Data Exposure
|
||||
|
||||
Description: OAuth application client secrets and SSO provider credentials are stored as plaintext, inconsistent with encrypted GitHub/Gitea tokens.
|
||||
|
||||
Exploit Scenario: Database leak exposes OAuth client secrets, allowing attackers to impersonate the application to external OAuth providers.
|
||||
|
||||
Recommendation:
|
||||
// Encrypt before storage
|
||||
import { encrypt } from "@/lib/utils/encryption";
|
||||
|
||||
await db.insert(oauthApplications).values({
|
||||
clientSecret: encrypt(clientSecret),
|
||||
// ...
|
||||
});
|
||||
|
||||
// Add decryption helper
|
||||
export function decryptOAuthClientSecret(encrypted: string): string {
|
||||
return decrypt(encrypted);
|
||||
}
|
||||
|
||||
---
|
||||
Vuln 8: SSRF in OIDC Discovery - src/pages/api/sso/discover.ts:48
|
||||
|
||||
Severity: HIGHConfidence: 0.95Category: Server-Side Request Forgery
|
||||
|
||||
Description: The endpoint accepts user-provided issuer URL and fetches ${issuer}/.well-known/openid-configuration without validating against internal
|
||||
networks.
|
||||
|
||||
Exploit Scenario:
|
||||
POST /api/sso/discover
|
||||
{"issuer": "http://169.254.169.254/latest/meta-data"}
|
||||
|
||||
Result: Access to AWS metadata service, internal APIs, or port scanning.
|
||||
|
||||
Recommendation:
|
||||
// Block private IPs and localhost
|
||||
const ALLOWED_PROTOCOLS = ['https:'];
|
||||
const hostname = parsedIssuer.hostname.toLowerCase();
|
||||
|
||||
if (
|
||||
!ALLOWED_PROTOCOLS.includes(parsedIssuer.protocol) ||
|
||||
hostname === 'localhost' ||
|
||||
hostname.match(/^10\./) ||
|
||||
hostname.match(/^192\.168\./) ||
|
||||
hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./) ||
|
||||
hostname.match(/^169\.254\./) ||
|
||||
hostname.match(/^127\./)
|
||||
) {
|
||||
return new Response(JSON.stringify({ error: "Invalid issuer" }), { status: 400 });
|
||||
}
|
||||
|
||||
---
|
||||
Vuln 9: SSRF in Gitea Connection Test - src/pages/api/gitea/test-connection.ts:29
|
||||
|
||||
Severity: HIGHConfidence: 0.95Category: SSRF / Input Validation
|
||||
|
||||
Description: Accepts user-provided url and makes HTTP request without validation.
|
||||
|
||||
Exploit Scenario: Same as Vuln 8 - internal network scanning, metadata service access.
|
||||
|
||||
Recommendation: Apply same URL validation as Vuln 8.
|
||||
|
||||
---
|
||||
Vuln 10: Command Injection in Docker Build - scripts/build-docker.sh:72
|
||||
|
||||
Severity: HIGHConfidence: 0.95Category: Command Injection
|
||||
|
||||
Description: Uses eval with dynamically constructed Docker command including environment variables.
|
||||
|
||||
Exploit Scenario:
|
||||
DOCKER_IMAGE='test; rm -rf /; #'
|
||||
./scripts/build-docker.sh
|
||||
|
||||
Recommendation:
|
||||
# Replace eval with direct execution
|
||||
if docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t "$FULL_IMAGE_NAME" \
|
||||
${LOAD:+--load} \
|
||||
${PUSH:+--push} \
|
||||
.; then
|
||||
echo "Success"
|
||||
fi
|
||||
|
||||
---
|
||||
Vuln 11: Path Traversal in Gitea API - src/lib/gitea.ts:193
|
||||
|
||||
Severity: MEDIUM-HIGHConfidence: 0.85Category: Path Traversal / SSRF
|
||||
|
||||
Description: Repository owner/name interpolated into URLs without encoding.
|
||||
|
||||
Exploit Scenario:
|
||||
owner = "../../admin"
|
||||
repoName = "users/../../../config"
|
||||
// Results in: /api/v1/repos/../../admin/users/../../../config
|
||||
|
||||
Recommendation:
|
||||
const response = await fetch(
|
||||
`${config.url}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`,
|
||||
// ...
|
||||
);
|
||||
|
||||
Apply to all URL constructions in gitea.ts and gitea-enhanced.ts.
|
||||
|
||||
---
|
||||
MEDIUM SEVERITY VULNERABILITIES
|
||||
|
||||
Vuln 12: Weak Session Configuration - src/lib/auth.ts:105
|
||||
|
||||
Severity: MEDIUMConfidence: 0.85
|
||||
|
||||
Description: 30-day session expiration is excessive; missing explicit security flags.
|
||||
|
||||
Recommendation:
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days max
|
||||
cookieOptions: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
},
|
||||
}
|
||||
|
||||
---
|
||||
Vuln 13: Missing CSRF Protection
|
||||
|
||||
Severity: MEDIUMConfidence: 0.80
|
||||
|
||||
Description: No visible CSRF token validation on state-changing operations.
|
||||
|
||||
Recommendation: Verify Better Auth CSRF protection is enabled; implement explicit tokens if needed.
|
||||
|
||||
---
|
||||
Vuln 14: Weak Random Number Generation - src/lib/utils.ts:12
|
||||
|
||||
Severity: MEDIUMConfidence: 0.85
|
||||
|
||||
Description: generateRandomString() uses Math.random() (not cryptographically secure) for OAuth client credentials.
|
||||
|
||||
Recommendation:
|
||||
// Use existing secure function
|
||||
const clientId = `client_${generateSecureToken(16)}`;
|
||||
const clientSecret = `secret_${generateSecureToken(24)}`;
|
||||
|
||||
---
|
||||
Vuln 15-18: Input Validation Issues
|
||||
|
||||
Severity: MEDIUMConfidence: 0.85
|
||||
|
||||
Affected:
|
||||
- src/pages/api/repositories/[id].ts:24 - destinationOrg not validated
|
||||
- src/pages/api/organizations/[id].ts:24 - destinationOrg not validated
|
||||
- src/pages/api/job/mirror-repo.ts:19 - repositoryIds array not validated
|
||||
- src/lib/helpers.ts:49 - Repository names in messages not sanitized
|
||||
|
||||
Recommendation: Use Zod schemas for all user inputs:
|
||||
const updateRepoSchema = z.object({
|
||||
destinationOrg: z.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9\-_\.]+$/)
|
||||
.nullable()
|
||||
.optional()
|
||||
});
|
||||
|
||||
---
|
||||
LOW SEVERITY VULNERABILITIES
|
||||
|
||||
Vuln 19: XSS via Astro set:html - src/pages/docs/quickstart.astro:102
|
||||
|
||||
Severity: LOWConfidence: 0.85
|
||||
|
||||
Description: Uses set:html with static data; risky pattern if copied with dynamic content.
|
||||
|
||||
Recommendation:
|
||||
<p class="text-sm" set:text={item.text}></p>
|
||||
|
||||
---
|
||||
Vuln 20: Open Redirect in OAuth - src/components/oauth/ConsentPage.tsx:118
|
||||
|
||||
Severity: LOWConfidence: 0.90
|
||||
|
||||
Description: OAuth redirect uses window.location.href without explicit protocol validation.
|
||||
|
||||
Recommendation:
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
window.location.assign(url.toString());
|
||||
|
||||
---
|
||||
POSITIVE SECURITY FINDINGS ✅
|
||||
|
||||
The codebase demonstrates excellent security practices:
|
||||
|
||||
1. SQL Injection Protection - Drizzle ORM with parameterized queries throughout
|
||||
2. Token Encryption - AES-256-GCM for GitHub/Gitea tokens
|
||||
3. XSS Protection - React auto-escaping, no dangerouslySetInnerHTML found
|
||||
4. Password Hashing - bcrypt via Better Auth
|
||||
5. Secure Random - crypto.randomBytes() in encryption functions
|
||||
6. Input Validation - Comprehensive Zod schemas
|
||||
7. Safe Path Operations - path.join() with proper base directories
|
||||
8. No Command Execution - TypeScript codebase doesn't use child_process
|
||||
9. Authentication Framework - Better Auth with session management
|
||||
10. Type Safety - TypeScript strict mode
|
||||
|
||||
---
|
||||
REMEDIATION PRIORITY
|
||||
|
||||
Phase 1: EMERGENCY (Deploy Today)
|
||||
|
||||
1. Fix BOLA vulnerabilities - Add authentication and userId validation (Vuln 1)
|
||||
2. Disable or secure header auth - Add IP whitelist validation (Vuln 2)
|
||||
3. Add authentication checks - Protect unprotected endpoints (Vuln 3)
|
||||
|
||||
Phase 2: URGENT (This Week)
|
||||
|
||||
1. Implement token masking - Don't return decrypted tokens (Vuln 4)
|
||||
2. Fix SSRF vulnerabilities - Add URL validation (Vuln 8, 9)
|
||||
3. Fix command injection - Remove eval from build script (Vuln 10)
|
||||
4. Add URL encoding - Path traversal fix (Vuln 11)
|
||||
|
||||
Phase 3: HIGH (This Sprint)
|
||||
|
||||
1. Fix KDF salt - Generate unique salt per installation (Vuln 5)
|
||||
2. Enforce strong secrets - Fail if weak defaults used (Vuln 6)
|
||||
3. Encrypt OAuth secrets - Match GitHub/Gitea token encryption (Vuln 7)
|
||||
4. Reduce session expiration - 7 days maximum (Vuln 12)
|
||||
5. Add CSRF protection - Explicit tokens (Vuln 13)
|
||||
|
||||
Phase 4: MEDIUM (Next Sprint)
|
||||
|
||||
1. Input validation - Zod schemas for all endpoints (Vuln 14-18)
|
||||
2. Fix weak RNG - Use crypto.randomBytes (Vuln 14)
|
||||
|
||||
Phase 5: LOW (Backlog)
|
||||
|
||||
1. Remove set:html - Use safer alternatives (Vuln 19)
|
||||
2. OAuth redirect validation - Add protocol check (Vuln 20)
|
||||
|
||||
---
|
||||
TESTING RECOMMENDATIONS
|
||||
|
||||
Proof of Concept Tests
|
||||
|
||||
# Test BOLA
|
||||
curl -X POST http://localhost:4321/api/config/index \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Cookie: better-auth-session=<attacker-session>" \
|
||||
-d '{"userId":"victim-id","githubConfig":{...}}'
|
||||
|
||||
# Test header spoofing
|
||||
curl http://localhost:4321/api/config/index?userId=admin \
|
||||
-H "X-Authentik-Username: admin" \
|
||||
-H "X-Authentik-Email: admin@example.com"
|
||||
|
||||
# Test SSRF
|
||||
curl -X POST http://localhost:4321/api/sso/discover \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"issuer":"http://169.254.169.254/latest/meta-data"}'
|
||||
|
||||
---
|
||||
SECURITY GRADE
|
||||
|
||||
Overall Security Grade: C+
|
||||
- Would be B+ after Phase 1-2 fixes
|
||||
- Would be A- after all fixes
|
||||
|
||||
Critical Issues: 2 (authorization bypass, auth spoofing)Risk Level: HIGH due to BOLA allowing complete account takeover
|
||||
|
||||
---
|
||||
CONCLUSION
|
||||
|
||||
The Gitea Mirror application has a strong security foundation with proper encryption, ORM usage, and XSS protection. However, critical authorization
|
||||
flaws in API endpoints allow authenticated users to access any other user's data. These must be fixed immediately before production deployment.
|
||||
|
||||
The development team has clearly prioritized security (encryption, input validation, type safety), and the identified issues are fixable with
|
||||
moderate effort. After remediation, this will be a secure, production-ready application.
|
||||
74
package.json
74
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.9.2",
|
||||
"version": "3.9.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -38,78 +38,78 @@
|
||||
"astro": "bunx --bun astro"
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
|
||||
"devalue": "^5.5.0"
|
||||
"@esbuild-kit/esm-loader": "npm:tsx@^4.20.5",
|
||||
"devalue": "^5.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.6",
|
||||
"@astrojs/mdx": "4.3.12",
|
||||
"@astrojs/node": "9.5.1",
|
||||
"@astrojs/react": "^4.4.2",
|
||||
"@better-auth/sso": "1.4.5",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@astrojs/check": "^0.9.5",
|
||||
"@astrojs/mdx": "4.3.7",
|
||||
"@astrojs/node": "9.5.0",
|
||||
"@astrojs/react": "^4.4.0",
|
||||
"@better-auth/sso": "1.4.0-beta.12",
|
||||
"@octokit/plugin-throttling": "^11.0.2",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tailwindcss/vite": "^4.1.15",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^5.16.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"astro": "^5.14.8",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"buffer": "^6.0.3",
|
||||
"better-auth": "1.4.5",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"better-auth": "1.4.0-beta.13",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.555.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.13"
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/bun": "^1.3.3",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"jsdom": "^27.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"vitest": "^4.0.15"
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.20.6",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"packageManager": "bun@1.3.3"
|
||||
"packageManager": "bun@1.2.23"
|
||||
}
|
||||
|
||||
113
src/lib/gitea.ts
113
src/lib/gitea.ts
@@ -1993,11 +1993,7 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
let skippedCount = 0;
|
||||
|
||||
const getReleaseTimestamp = (release: typeof releases.data[number]) => {
|
||||
// Use published_at first (when the release was published on GitHub)
|
||||
// Fall back to created_at (when the git tag was created) only if published_at is missing
|
||||
// This matches GitHub's sorting behavior and handles cases where multiple tags
|
||||
// point to the same commit but have different publish dates
|
||||
const sourceDate = release.published_at ?? release.created_at ?? "";
|
||||
const sourceDate = release.created_at ?? release.published_at ?? "";
|
||||
const timestamp = sourceDate ? new Date(sourceDate).getTime() : 0;
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
};
|
||||
@@ -2009,95 +2005,16 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
.slice(0, releaseLimit)
|
||||
.sort((a, b) => getReleaseTimestamp(a) - getReleaseTimestamp(b));
|
||||
|
||||
console.log(`[Releases] Processing ${releasesToProcess.length} releases in chronological order (oldest to newest by published date)`);
|
||||
console.log(`[Releases] Processing ${releasesToProcess.length} releases in chronological order (oldest to newest)`);
|
||||
releasesToProcess.forEach((rel, idx) => {
|
||||
const publishedDate = new Date(rel.published_at || rel.created_at);
|
||||
const createdDate = new Date(rel.created_at);
|
||||
const dateInfo = rel.published_at !== rel.created_at
|
||||
? `published ${publishedDate.toISOString()} (tag created ${createdDate.toISOString()})`
|
||||
: `published ${publishedDate.toISOString()}`;
|
||||
console.log(`[Releases] ${idx + 1}. ${rel.tag_name} - ${dateInfo}`);
|
||||
const date = new Date(rel.published_at || rel.created_at);
|
||||
console.log(`[Releases] ${idx + 1}. ${rel.tag_name} - Originally published: ${date.toISOString()}`);
|
||||
});
|
||||
|
||||
// Check if existing releases in Gitea are in the wrong order
|
||||
// If so, we need to delete and recreate them to fix the ordering
|
||||
let needsRecreation = false;
|
||||
try {
|
||||
const existingReleasesResponse = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases?per_page=100`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
).catch(() => null);
|
||||
|
||||
if (existingReleasesResponse && existingReleasesResponse.data && Array.isArray(existingReleasesResponse.data)) {
|
||||
const existingReleases = existingReleasesResponse.data;
|
||||
|
||||
if (existingReleases.length > 0) {
|
||||
console.log(`[Releases] Found ${existingReleases.length} existing releases in Gitea, checking chronological order...`);
|
||||
|
||||
// Create a map of tag_name to expected chronological index (0 = oldest, n = newest)
|
||||
const expectedOrder = new Map<string, number>();
|
||||
releasesToProcess.forEach((rel, idx) => {
|
||||
expectedOrder.set(rel.tag_name, idx);
|
||||
});
|
||||
|
||||
// Check if existing releases are in the correct order based on created_unix
|
||||
// Gitea sorts by created_unix DESC, so newer releases should have higher created_unix values
|
||||
const releasesThatShouldExist = existingReleases.filter(r => expectedOrder.has(r.tag_name));
|
||||
|
||||
if (releasesThatShouldExist.length > 1) {
|
||||
for (let i = 0; i < releasesThatShouldExist.length - 1; i++) {
|
||||
const current = releasesThatShouldExist[i];
|
||||
const next = releasesThatShouldExist[i + 1];
|
||||
|
||||
const currentExpectedIdx = expectedOrder.get(current.tag_name)!;
|
||||
const nextExpectedIdx = expectedOrder.get(next.tag_name)!;
|
||||
|
||||
// Since Gitea returns releases sorted by created_unix DESC:
|
||||
// - Earlier releases in the list should have HIGHER expected indices (newer)
|
||||
// - Later releases in the list should have LOWER expected indices (older)
|
||||
if (currentExpectedIdx < nextExpectedIdx) {
|
||||
console.log(`[Releases] ⚠️ Incorrect ordering detected: ${current.tag_name} (index ${currentExpectedIdx}) appears before ${next.tag_name} (index ${nextExpectedIdx})`);
|
||||
needsRecreation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsRecreation) {
|
||||
console.log(`[Releases] ⚠️ Releases are in incorrect chronological order. Will delete and recreate all releases.`);
|
||||
|
||||
// Delete all existing releases that we're about to recreate
|
||||
for (const existingRelease of releasesThatShouldExist) {
|
||||
try {
|
||||
console.log(`[Releases] Deleting incorrectly ordered release: ${existingRelease.tag_name}`);
|
||||
await httpDelete(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
} catch (deleteError) {
|
||||
console.error(`[Releases] Failed to delete release ${existingRelease.tag_name}: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Releases] ✅ Deleted ${releasesThatShouldExist.length} releases. Will recreate in correct chronological order.`);
|
||||
} else {
|
||||
console.log(`[Releases] ✅ Existing releases are in correct chronological order.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (orderCheckError) {
|
||||
console.warn(`[Releases] Could not verify release order: ${orderCheckError instanceof Error ? orderCheckError.message : String(orderCheckError)}`);
|
||||
// Continue with normal processing
|
||||
}
|
||||
|
||||
for (const release of releasesToProcess) {
|
||||
try {
|
||||
// Check if release already exists (skip check if we just deleted all releases)
|
||||
const existingReleasesResponse = needsRecreation ? null : await httpGet(
|
||||
// Check if release already exists
|
||||
const existingReleasesResponse = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/tags/${release.tag_name}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
@@ -2106,21 +2023,9 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
|
||||
// Prepare release body with GitHub original date header
|
||||
const githubPublishedDate = release.published_at || release.created_at;
|
||||
const githubTagCreatedDate = release.created_at;
|
||||
|
||||
let githubDateHeader = '';
|
||||
if (githubPublishedDate) {
|
||||
githubDateHeader = `> 📅 **Originally published on GitHub:** ${new Date(githubPublishedDate).toUTCString()}`;
|
||||
|
||||
// If the tag was created on a different date than the release was published,
|
||||
// show both dates (helps with repos that create multiple tags from the same commit)
|
||||
if (release.published_at && release.created_at && release.published_at !== release.created_at) {
|
||||
githubDateHeader += `\n> 🏷️ **Git tag created:** ${new Date(githubTagCreatedDate).toUTCString()}`;
|
||||
}
|
||||
|
||||
githubDateHeader += '\n\n';
|
||||
}
|
||||
|
||||
const githubDateHeader = githubPublishedDate
|
||||
? `> 📅 **Originally published on GitHub:** ${new Date(githubPublishedDate).toUTCString()}\n\n`
|
||||
: '';
|
||||
const originalReleaseNote = release.body || "";
|
||||
const releaseNote = githubDateHeader + originalReleaseNote;
|
||||
|
||||
|
||||
@@ -9,28 +9,28 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.12",
|
||||
"@astrojs/react": "^4.4.2",
|
||||
"@astrojs/mdx": "^4.3.7",
|
||||
"@astrojs/react": "^4.4.0",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@splinetool/react-spline": "^4.1.0",
|
||||
"@splinetool/runtime": "^1.12.5",
|
||||
"@splinetool/runtime": "^1.10.85",
|
||||
"@tailwindcss/vite": "^4.1.15",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^5.16.4",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"astro": "^5.14.8",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17"
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.24.0"
|
||||
}
|
||||
"packageManager": "pnpm@10.18.3"
|
||||
}
|
||||
570
www/pnpm-lock.yaml
generated
570
www/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
||||
---
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: "What is Gitea Mirror and why do I need it?",
|
||||
answer: "Gitea Mirror is a self-hosted tool that automatically backs up your GitHub repositories to your own Gitea server. You need it because GitHub outages, account issues, or policy changes can lock you out of your code. With Gitea Mirror, you own your backups completely—no monthly fees, no third-party storage, just full control over your repository history, issues, pull requests, and releases."
|
||||
},
|
||||
{
|
||||
question: "How is this different from BackHub or Rewind?",
|
||||
answer: "BackHub and Rewind are cloud services that cost $600-2400/year and store your code on their servers. Gitea Mirror is free, open source, and runs on your own infrastructure. You pay $0/month and have complete data ownership. The tradeoff: you manage the infrastructure yourself, while cloud services are fully managed."
|
||||
},
|
||||
{
|
||||
question: "Does Gitea Mirror backup issues, pull requests, and releases?",
|
||||
answer: "Yes! Enable 'Mirror metadata' in settings to backup issues with comments, labels, and assignees. Pull requests are mirrored as enriched issues with full metadata, commit history, and file changes (Gitea's API doesn't support creating PRs from external sources). Releases, including binary assets, and wiki pages are also backed up when enabled."
|
||||
},
|
||||
{
|
||||
question: "How long does setup take?",
|
||||
answer: "15-20 minutes with Docker. Run 'docker compose -f docker-compose.alt.yml up -d', visit localhost:4321, create an account, paste your GitHub and Gitea tokens, select repos to backup—done. The Proxmox LXC one-liner is even faster. No complex configuration files or manual scripting required."
|
||||
},
|
||||
{
|
||||
question: "What happens if GitHub goes down or I lose access?",
|
||||
answer: "You can immediately clone from your Gitea server instead. Your local backups include full commit history, branches, tags, issues, releases—everything. Recovery time is typically under 2 minutes (just point git to your Gitea URL). This is why it's called disaster recovery, not just mirroring."
|
||||
},
|
||||
{
|
||||
question: "How often does it sync with GitHub?",
|
||||
answer: "Configurable from every 15 minutes to once per day (or longer). Most users choose 1-8 hours based on how fresh they want backups. The scheduler auto-discovers new repos and respects per-repo intervals, unlike Gitea's built-in mirroring which defaults to 24 hours."
|
||||
},
|
||||
{
|
||||
question: "Can I backup starred repositories?",
|
||||
answer: "Yes! Gitea Mirror can automatically backup all your GitHub stars into a dedicated Gitea organization. Perfect for preserving important open source projects before they disappear (projects get deleted, renamed, or removed all the time)."
|
||||
},
|
||||
{
|
||||
question: "What are the system requirements?",
|
||||
answer: "Minimal: 2 vCPU, 2GB RAM, 5-10GB storage (grows with repo count). Runs on Docker, Kubernetes, Proxmox LXC, or bare metal. Works on AMD64 and ARM64 (Raspberry Pi compatible). A small homelab server or cheap VPS is plenty for personal use."
|
||||
},
|
||||
{
|
||||
question: "Is my GitHub token stored securely?",
|
||||
answer: "Yes. All GitHub and Gitea tokens are encrypted at rest using AES-256-GCM. Even if someone gains access to the SQLite database, they can't read your tokens without the encryption key. Use the ENCRYPTION_SECRET environment variable for additional security."
|
||||
},
|
||||
{
|
||||
question: "Can I backup private repositories?",
|
||||
answer: "Absolutely. Just use a GitHub personal access token (classic) with 'repo' scope enabled. Gitea Mirror will backup all repositories you have access to—public, private, and internal."
|
||||
},
|
||||
{
|
||||
question: "What if I have multiple GitHub organizations?",
|
||||
answer: "Gitea Mirror supports multiple organizations with flexible destination strategies: preserve GitHub structure in Gitea, consolidate into a single org, or use mixed mode (personal repos in one place, org repos preserve structure). Edit individual organization destinations via the dashboard."
|
||||
},
|
||||
{
|
||||
question: "Does it support Git LFS (large files)?",
|
||||
answer: "Yes! Enable 'Mirror LFS' in settings. Make sure your Gitea server has LFS enabled (LFS_START_SERVER = true) and Git v2.1.2+. Large assets like videos, datasets, and binaries are backed up alongside your code."
|
||||
},
|
||||
{
|
||||
question: "How do I restore from backup?",
|
||||
answer: "Simple: 'git clone https://your-gitea-server/owner/repo.git'. Your full history, branches, and tags are there. For issues/PRs/releases, they're already in Gitea's web interface. For complete disaster recovery, restore the data volume to a fresh Gitea Mirror instance—everything (config, sync history) is preserved."
|
||||
},
|
||||
{
|
||||
question: "Can I run this alongside a cloud backup service?",
|
||||
answer: "Yes! Many users run Gitea Mirror for local/warm backups while using cloud services for offsite redundancy. Best of both worlds: instant local recovery and geographic disaster protection. Totally compatible."
|
||||
},
|
||||
{
|
||||
question: "Is this enterprise-ready with SLA guarantees?",
|
||||
answer: "No. Gitea Mirror is a community open source project—no 24/7 support, no compliance certifications, no guaranteed uptime. It's perfect for homelabs, indie developers, and small teams comfortable with self-hosting. If you need enterprise SLAs, consider commercial solutions like BackHub or GitHub Enterprise Backup."
|
||||
}
|
||||
];
|
||||
|
||||
// Generate FAQ schema for SEO
|
||||
const faqSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": faqs.map(faq => ({
|
||||
"@type": "Question",
|
||||
"name": faq.question,
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": faq.answer
|
||||
}
|
||||
}))
|
||||
};
|
||||
---
|
||||
|
||||
<section id="faq" class="py-16 sm:py-24 px-4 sm:px-6 lg:px-8 bg-muted/30">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="text-center mb-12 sm:mb-16">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-4">
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
FAQ
|
||||
</span>
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight">
|
||||
Common Questions About GitHub Backups
|
||||
</h2>
|
||||
<p class="mt-4 text-base sm:text-lg text-muted-foreground">
|
||||
Everything you need to know about self-hosted repository backups with Gitea Mirror.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<details class="group rounded-xl border bg-background/80 p-6 shadow-sm transition-all hover:shadow-md open:ring-1 open:ring-primary/20">
|
||||
<summary class="flex cursor-pointer items-start justify-between gap-4 font-semibold text-foreground list-none">
|
||||
<span class="text-left">{faq.question}</span>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-muted-foreground transition-transform group-open:rotate-180"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<p class="mt-4 text-sm sm:text-base text-muted-foreground leading-relaxed">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Still have questions?
|
||||
<a href="https://github.com/RayLabsHQ/gitea-mirror/discussions" class="text-primary hover:text-primary/80 font-medium transition-colors ml-1">
|
||||
Ask in our GitHub Discussions
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ Schema for SEO -->
|
||||
<script type="application/ld+json" set:html={JSON.stringify(faqSchema)} />
|
||||
|
||||
<style>
|
||||
details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -22,12 +22,9 @@ export function Header() {
|
||||
];
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed left-0 right-0 z-50 transition-all duration-300 ${
|
||||
isScrolled ? 'backdrop-blur-lg bg-background/80 border-b shadow-sm' : 'bg-background/50'
|
||||
}`}
|
||||
style={{ top: 'var(--promo-banner-height, 0px)' }}
|
||||
>
|
||||
<header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
isScrolled ? 'backdrop-blur-lg bg-background/80 border-b shadow-sm' : 'bg-background/50'
|
||||
}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from "./ui/button";
|
||||
import { ArrowRight, Shield, RefreshCw, HardDrive } from "lucide-react";
|
||||
import { ArrowRight, Shield, RefreshCw } from "lucide-react";
|
||||
import { GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||
import React, { Suspense } from 'react';
|
||||
|
||||
@@ -39,30 +39,31 @@ export function Hero() {
|
||||
<div className="clip-avoid w-full h-[16rem] md:h-[20rem] lg:h-[12rem] 2xl:h-[16rem]" aria-hidden="true"></div>
|
||||
<div className="max-w-7xl mx-auto pb-20 lg:pb-60 xl:pb-24 text-center w-full">
|
||||
<h1 className="pt-10 2xl:pt-20 text-3xl xs:text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold tracking-tight leading-tight">
|
||||
<span className="text-foreground">Backup Your GitHub</span>
|
||||
<span className="text-foreground">Keep Your Code</span>
|
||||
<br />
|
||||
<span className="text-gradient from-primary via-accent to-accent-purple">
|
||||
To Self-Hosted Gitea
|
||||
Safe & Synced
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 sm:mt-6 text-base sm:text-lg md:text-xl text-muted-foreground max-w-3xl mx-auto px-4 z-20">
|
||||
Automatic, private, and free. Own your code history forever.
|
||||
Preserve issues, PRs, releases, and wiki in your own Gitea server.
|
||||
Automatically mirror your GitHub repositories to self-hosted Gitea.
|
||||
Never lose access to your code with continuous backup and
|
||||
synchronization.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 sm:mt-8 flex flex-wrap items-center justify-center gap-3 text-xs sm:text-sm text-muted-foreground px-4 z-20">
|
||||
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary">
|
||||
<HardDrive className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="font-medium">Self-Hosted Backup</span>
|
||||
<Shield className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="font-medium">Self-Hosted</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-accent/10 text-accent">
|
||||
<RefreshCw className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="font-medium">Automated Syncing</span>
|
||||
<span className="font-medium">Auto-Sync</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-accent-purple/10 text-accent-purple">
|
||||
<Shield className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="font-medium">$0/month</span>
|
||||
<GitHubLogoIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="font-medium">Open Source</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Calendar, Sparkles } from 'lucide-react';
|
||||
|
||||
export function PromoBanner() {
|
||||
const bannerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Update CSS custom property for header offset
|
||||
const updateOffset = () => {
|
||||
if (bannerRef.current) {
|
||||
const height = bannerRef.current.offsetHeight;
|
||||
document.documentElement.style.setProperty('--promo-banner-height', `${height}px`);
|
||||
}
|
||||
};
|
||||
|
||||
updateOffset();
|
||||
window.addEventListener('resize', updateOffset);
|
||||
return () => window.removeEventListener('resize', updateOffset);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={bannerRef}
|
||||
className="fixed top-0 left-0 right-0 z-[60] bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-600 text-white"
|
||||
>
|
||||
<a
|
||||
href="https://lumical.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-x-3 text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="font-medium">New from RayLabs:</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 font-semibold">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Lumical
|
||||
</span>
|
||||
<span className="hidden sm:inline text-white/90">
|
||||
— Scan meeting invites to your calendar with AI
|
||||
</span>
|
||||
<span className="ml-1 inline-flex items-center gap-1 rounded-full bg-white/20 px-3 py-0.5 text-xs font-medium">
|
||||
Try it free
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
---
|
||||
layout: ../../layouts/UseCaseLayout.astro
|
||||
title: "GitHub Backup Tools Compared: Self-Hosted vs Cloud Solutions"
|
||||
description: "Compare Gitea Mirror with BackHub, Rewind, GitHub Enterprise Backup, and manual scripts. Choose the best GitHub backup solution for your needs."
|
||||
canonical: "https://gitea-mirror.com/comparison/github-backup-tools/"
|
||||
---
|
||||
|
||||
# GitHub Backup Tools Compared: Finding the Right Solution
|
||||
|
||||
## Why GitHub backups matter
|
||||
|
||||
GitHub hosts millions of repositories, but relying on a single platform comes with risks:
|
||||
|
||||
- **Outages**: GitHub experiences downtime (most recent: November 2024, October 2024, August 2024)
|
||||
- **Account issues**: DMCA takedowns, TOS violations, or account suspensions can lock you out
|
||||
- **Accidental deletions**: One wrong click and your repo history vanishes
|
||||
- **Company changes**: Microsoft's acquisition led to policy shifts many developers disagreed with
|
||||
- **Data sovereignty**: GDPR, HIPAA, or internal policies may require local data control
|
||||
|
||||
The question isn't *if* you need backups—it's *which solution* fits your workflow and budget.
|
||||
|
||||
## Comparison at a glance
|
||||
|
||||
| Feature | Gitea Mirror | BackHub | Rewind Backups | GitHub Enterprise Backup | Manual Scripts |
|
||||
|---------|--------------|---------|----------------|-------------------------|----------------|
|
||||
| **Cost/Year** | $0 | $600+ | $240+ | $21,000+ | $0 |
|
||||
| **Self-Hosted** | ✅ | ❌ | ❌ | Optional | ✅ |
|
||||
| **Setup Time** | 15 min | 5 min | 5 min | Days | 2+ hours |
|
||||
| **Metadata (Issues/PRs)** | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Releases & Assets** | ✅ | ✅ | ✅ | ✅ | Partial |
|
||||
| **Wiki Backup** | ✅ | ✅ | ✅ | ✅ | Partial |
|
||||
| **Git LFS Support** | ✅ | ✅ | ✅ | ✅ | Manual |
|
||||
| **Auto-Discovery** | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Scheduled Syncing** | ✅ | ✅ | ✅ | ✅ | Cron-based |
|
||||
| **Data Ownership** | ✅ Full | ❌ | ❌ | ✅ | ✅ Full |
|
||||
| **Restore Complexity** | Low | Medium | Medium | Low | High |
|
||||
| **Multi-Org Support** | ✅ | ✅ | ✅ | ✅ | Manual |
|
||||
| **Real-Time Dashboard** | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
|
||||
## Solution breakdown
|
||||
|
||||
### Gitea Mirror (Self-Hosted, Free)
|
||||
|
||||
**Best for**: Homelab enthusiasts, indie developers, privacy-conscious teams, cost-sensitive startups
|
||||
|
||||
**How it works**: Automatically mirrors your GitHub repositories to your own Gitea server using scheduled syncs. Preserves full history, metadata (issues, PRs as enriched issues, labels, milestones), releases, and wiki content.
|
||||
|
||||
**Pros**:
|
||||
- ✅ **Zero recurring costs** - Just your hosting/hardware
|
||||
- ✅ **Complete data ownership** - Everything stays on your infrastructure
|
||||
- ✅ **Privacy-first** - No third parties touch your code
|
||||
- ✅ **Customizable** - Open source, fork it, extend it
|
||||
- ✅ **Docker-friendly** - One command deployment
|
||||
- ✅ **Multi-arch support** - AMD64 and ARM64 (Raspberry Pi compatible)
|
||||
- ✅ **No vendor lock-in** - Standard Git repos, portable
|
||||
|
||||
**Cons**:
|
||||
- ❌ You manage the infrastructure (server, backups, updates)
|
||||
- ❌ Community support only (no SLA)
|
||||
- ❌ Requires basic Docker/server knowledge
|
||||
- ❌ You're responsible for Gitea security updates
|
||||
|
||||
**Ideal user**: "I run a homelab with Proxmox/Docker and want full control over my GitHub backups without paying monthly fees."
|
||||
|
||||
**Setup**:
|
||||
```bash
|
||||
docker compose -f docker-compose.alt.yml up -d
|
||||
# Visit http://localhost:4321, create account, add tokens
|
||||
```
|
||||
|
||||
### BackHub (Cloud, $50-200/month)
|
||||
|
||||
**Best for**: Teams wanting zero infrastructure management, compliance-focused organizations
|
||||
|
||||
**How it works**: SaaS platform that backs up GitHub orgs/repos to their cloud storage. Offers point-in-time recovery and compliance features.
|
||||
|
||||
**Pros**:
|
||||
- ✅ Fully managed (zero infrastructure)
|
||||
- ✅ SOC 2 compliant
|
||||
- ✅ Easy restore interface
|
||||
- ✅ Supports all GitHub features
|
||||
|
||||
**Cons**:
|
||||
- ❌ $600-2400/year minimum
|
||||
- ❌ Your code lives on their servers
|
||||
- ❌ Vendor lock-in for restore process
|
||||
- ❌ Pricing scales with repo count
|
||||
- ❌ No self-hosted option
|
||||
|
||||
**Ideal user**: "I need GitHub backups with compliance guarantees and don't want to manage servers."
|
||||
|
||||
### Rewind Backups (Cloud, $20-100/month)
|
||||
|
||||
**Best for**: Small teams, agencies managing client repos
|
||||
|
||||
**How it works**: Cloud backup service that snapshots GitHub data daily/hourly with web-based restore.
|
||||
|
||||
**Pros**:
|
||||
- ✅ Simple setup (OAuth connection)
|
||||
- ✅ User-friendly restore UI
|
||||
- ✅ Supports multiple SaaS platforms (not just GitHub)
|
||||
|
||||
**Cons**:
|
||||
- ❌ $240-1200/year
|
||||
- ❌ Cloud-only storage
|
||||
- ❌ Restore requires their platform
|
||||
- ❌ Limited to their backup schedule
|
||||
- ❌ No local/offline access
|
||||
|
||||
**Ideal user**: "I backup multiple SaaS tools and want one dashboard for everything."
|
||||
|
||||
### GitHub Enterprise Backup Utilities (Self-Hosted, Requires GHE)
|
||||
|
||||
**Best for**: Large enterprises already on GitHub Enterprise
|
||||
|
||||
**How it works**: Official GitHub tool for backing up GitHub Enterprise Server instances. Creates encrypted backup snapshots.
|
||||
|
||||
**Pros**:
|
||||
- ✅ Official GitHub tool
|
||||
- ✅ Enterprise-grade
|
||||
- ✅ Compliance-ready
|
||||
|
||||
**Cons**:
|
||||
- ❌ Requires GitHub Enterprise license ($21k+/year for 10 users)
|
||||
- ❌ Only works with Enterprise Server, not GitHub.com
|
||||
- ❌ Complex setup and maintenance
|
||||
- ❌ Overkill for small teams
|
||||
|
||||
**Ideal user**: "We're already paying for GitHub Enterprise and need official backup tooling."
|
||||
|
||||
### Manual Git Scripts (DIY, Free but Brittle)
|
||||
|
||||
**Best for**: Minimalists with few repos and technical expertise
|
||||
|
||||
**How it works**: Cron jobs that run `git clone --mirror` for each repo.
|
||||
|
||||
**Pros**:
|
||||
- ✅ Zero cost
|
||||
- ✅ Simple concept
|
||||
- ✅ No third-party dependencies
|
||||
|
||||
**Cons**:
|
||||
- ❌ No metadata backup (issues, PRs, releases lost)
|
||||
- ❌ Manual discovery of new repos
|
||||
- ❌ No LFS support without extra work
|
||||
- ❌ Fails silently (no health checks)
|
||||
- ❌ High maintenance overhead
|
||||
- ❌ No organization/bulk operations
|
||||
- ❌ Wiki requires separate cloning logic
|
||||
|
||||
**Ideal user**: "I have 2-3 repos and enjoy writing Bash scripts."
|
||||
|
||||
## Decision framework
|
||||
|
||||
### Choose Gitea Mirror if you:
|
||||
- Want $0 recurring costs
|
||||
- Run a homelab or have spare hardware
|
||||
- Value data ownership and privacy
|
||||
- Are comfortable with Docker/basic sysadmin tasks
|
||||
- Need to back up personal repos or small org (<50 repos)
|
||||
- Want to learn self-hosting skills
|
||||
|
||||
### Choose BackHub if you:
|
||||
- Have budget for managed services ($50-200/mo)
|
||||
- Need SOC 2 compliance
|
||||
- Want zero infrastructure management
|
||||
- Back up 10+ organizations
|
||||
- Require guaranteed SLA
|
||||
|
||||
### Choose Rewind if you:
|
||||
- Back up multiple SaaS tools (not just GitHub)
|
||||
- Have limited budget ($20-50/mo)
|
||||
- Want dead-simple restore UX
|
||||
- Don't need self-hosted storage
|
||||
|
||||
### Choose GitHub Enterprise Backup if you:
|
||||
- Already have GitHub Enterprise Server
|
||||
- Need official support contracts
|
||||
- Operate at enterprise scale (hundreds of users)
|
||||
- Have compliance requirements for official tooling
|
||||
|
||||
### Choose Manual Scripts if you:
|
||||
- Have <5 repos
|
||||
- Don't care about metadata/issues/PRs
|
||||
- Enjoy scripting and troubleshooting
|
||||
- Have time to maintain cron jobs
|
||||
|
||||
## Real-world cost comparison (5-year total)
|
||||
|
||||
Assuming 50 repositories, 10 active contributors:
|
||||
|
||||
| Solution | Year 1 | Year 5 Total | Notes |
|
||||
|----------|--------|--------------|-------|
|
||||
| **Gitea Mirror** | $0-50 | $0-250 | Only hardware/hosting costs (VPS: ~$50/yr) |
|
||||
| **BackHub** | $1,200 | $6,000 | Scales with repos |
|
||||
| **Rewind** | $600 | $3,000 | Mid-tier plan |
|
||||
| **GitHub Enterprise Backup** | $21,000+ | $105,000+ | Requires GHE license |
|
||||
| **Manual Scripts** | $0 | $0 | Plus hundreds of hours maintaining |
|
||||
|
||||
## Migration paths
|
||||
|
||||
### From cloud backup to Gitea Mirror
|
||||
1. Export backup data from your cloud provider (if supported)
|
||||
2. Deploy Gitea Mirror following the [backup playbook](/use-cases/backup-github-repositories/)
|
||||
3. Let Gitea Mirror rebuild from GitHub (preserves all metadata)
|
||||
4. Cancel cloud subscription once validated
|
||||
|
||||
### From manual scripts to Gitea Mirror
|
||||
1. Note your current repo list
|
||||
2. Deploy Gitea Mirror and connect GitHub
|
||||
3. Auto-import handles discovery
|
||||
4. Delete cron jobs once sync confirmed
|
||||
|
||||
### From Gitea Mirror to other solutions
|
||||
Your repos are standard Git mirrors—clone from Gitea and push to any other service. Zero lock-in.
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
### Can I use Gitea Mirror alongside a cloud backup service?
|
||||
|
||||
Absolutely! Gitea Mirror adds a self-hosted "warm backup" layer while cloud services provide offsite redundancy. Best of both worlds.
|
||||
|
||||
### What if my hardware fails?
|
||||
|
||||
Snapshot your Gitea Mirror data volume regularly using ZFS, Btrfs, or tools like Restic. Store snapshots offsite. The GitHub → Gitea Mirror sync is idempotent—spin up a new instance, restore data volume, and it resumes.
|
||||
|
||||
### How does Gitea Mirror compare for disaster recovery?
|
||||
|
||||
**RTO (Recovery Time Objective)**: Minutes—just `git clone` from your Gitea server.
|
||||
**RPO (Recovery Point Objective)**: Depends on sync interval (15 min - 24 hours typical).
|
||||
|
||||
Cloud services have similar RPO but may have slower RTO if you need to restore hundreds of repos through a web UI.
|
||||
|
||||
### Can I try Gitea Mirror without committing?
|
||||
|
||||
Yes! The Docker setup takes 15 minutes. Test with a few repos, evaluate, then scale or remove with `docker compose down -v`.
|
||||
|
||||
### Does Gitea Mirror work with GitHub Enterprise Cloud?
|
||||
|
||||
Yes, as long as you have API access and a personal access token. The GitHub API endpoint is configurable.
|
||||
|
||||
## Next steps
|
||||
|
||||
- **Ready to self-host?** Follow the [GitHub backup playbook](/use-cases/backup-github-repositories/)
|
||||
- **Need multi-tenant?** See [Proxmox LXC deployment](/use-cases/proxmox-lxc-homelab/) or [Kubernetes Helm chart](/use-cases/deploy-with-helm-chart/)
|
||||
- **Questions?** [Open a GitHub discussion](https://github.com/RayLabsHQ/gitea-mirror/discussions)
|
||||
|
||||
## Honest assessment
|
||||
|
||||
Gitea Mirror is **not** enterprise SaaS. There's no 24/7 support, no compliance certifications, no guaranteed uptime. It's a well-maintained open source project built by and for developers who value ownership and cost-effectiveness.
|
||||
|
||||
If you need a pager number when things break, choose a commercial solution. If you enjoy owning your infrastructure and solving problems, Gitea Mirror saves thousands of dollars annually while giving you complete control.
|
||||
|
||||
The best backup solution is the one you'll actually use and test. Choose based on your skills, budget, and risk tolerance—not marketing promises.
|
||||
@@ -8,48 +8,44 @@ import UseCases from '../components/UseCases.astro';
|
||||
import Screenshots from '../components/Screenshots.astro';
|
||||
import { Installation } from '../components/Installation';
|
||||
import { CTA } from '../components/CTA';
|
||||
import FAQ from '../components/FAQ.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { PromoBanner } from '../components/PromoBanner';
|
||||
|
||||
const siteUrl = 'https://gitea-mirror.com';
|
||||
const title = 'GitHub Backup Tool | Self-Hosted Repository Backup to Gitea';
|
||||
const description = 'Automatically backup GitHub repos to your own Gitea server. Preserve issues, PRs, releases & wiki. Self-hosted, Docker-ready. Free alternative to cloud backup services.';
|
||||
const keywords = 'github backup, github backup self hosted, github repository backup, backup github to nas, github disaster recovery, offline github backup, github backup docker, automatic github backup, github account backup, gitea mirror, self-hosted git backup, repository sync, github to gitea, git mirror, code backup, self-hosted backup solution';
|
||||
const title = 'Gitea Mirror - Automated GitHub to Gitea Repository Mirroring & Backup';
|
||||
const description = 'Automatically mirror and backup your GitHub repositories to self-hosted Gitea. Keep your code safe with scheduled syncing, bulk operations, and real-time monitoring. Free and open source.';
|
||||
const keywords = 'github backup, gitea mirror, repository sync, github to gitea, git mirror, code backup, self-hosted git, repository migration, github mirror tool, gitea sync, automated backup, github repository backup, git repository mirror, self-hosted backup solution';
|
||||
|
||||
// Structured data for SEO
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Gitea Mirror",
|
||||
"applicationCategory": "BackupApplication",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Linux, macOS, Windows",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"description": "Automatic GitHub repository backup to self-hosted Gitea. Preserves complete history, issues, PRs, and releases. Free alternative to cloud backup services.",
|
||||
"description": description,
|
||||
"url": siteUrl,
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "RayLabs",
|
||||
"url": "https://github.com/RayLabsHQ"
|
||||
},
|
||||
"softwareVersion": "3.9.2",
|
||||
"softwareVersion": "2.22.0",
|
||||
"screenshot": [
|
||||
`${siteUrl}/assets/dashboard.png`,
|
||||
`${siteUrl}/assets/repositories.png`,
|
||||
`${siteUrl}/assets/organisation.png`
|
||||
],
|
||||
"featureList": [
|
||||
"Automated scheduled backups",
|
||||
"Self-hosted (full data ownership)",
|
||||
"Metadata preservation (issues, PRs, releases, wiki)",
|
||||
"Docker support",
|
||||
"Multi-repository backup",
|
||||
"Git LFS support",
|
||||
"Free and open source"
|
||||
"Automated repository mirroring",
|
||||
"Bulk organization sync",
|
||||
"Real-time monitoring",
|
||||
"Self-hosted solution",
|
||||
"Open source"
|
||||
],
|
||||
"softwareRequirements": "Docker or Bun runtime"
|
||||
};
|
||||
@@ -120,7 +116,6 @@ const structuredData = {
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-background text-foreground antialiased">
|
||||
<PromoBanner client:load />
|
||||
<Header client:load />
|
||||
|
||||
<main>
|
||||
@@ -132,7 +127,6 @@ const structuredData = {
|
||||
<UseCases />
|
||||
<Screenshots />
|
||||
<Installation client:load />
|
||||
<FAQ />
|
||||
<CTA client:load />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
---
|
||||
layout: ../../layouts/UseCaseLayout.astro
|
||||
title: "Self-Hosted GitHub Backup on Proxmox LXC | Gitea Mirror Homelab Setup"
|
||||
description: "Deploy a dedicated GitHub backup appliance in your Proxmox homelab using the one-line LXC installer. Automatic syncing, snapshot-ready, homelab-optimized."
|
||||
title: "Run Gitea Mirror inside a Proxmox LXC"
|
||||
description: "Provision the community-maintained Proxmox VE LXC container for Gitea Mirror and wire it into your homelab backup workflow."
|
||||
canonical: "https://gitea-mirror.com/use-cases/proxmox-lxc-homelab/"
|
||||
---
|
||||
|
||||
# Self-Hosted GitHub Backup on Proxmox LXC
|
||||
# Run Gitea Mirror inside a Proxmox LXC
|
||||
|
||||
## The Homelab GitHub Backup Appliance
|
||||
## Why run it on Proxmox
|
||||
|
||||
Running GitHub backups in your homelab means **complete data ownership** without monthly SaaS fees. When most of your infrastructure lives in Proxmox VE, the community LXC script is the fastest path from zero to a dedicated backup appliance.
|
||||
|
||||
It handles Bun runtime, systemd services, persistent storage, and future upgrades—so you can focus on keeping your GitHub history safe and synced locally. Perfect for homelabbers who want the peace of mind of offline backups without cloud dependencies.
|
||||
When most of your homelab lives in Proxmox VE, the community LXC script is the fastest path from zero to a managed Gitea Mirror node. It handles Bun, systemd, persistent storage, and future upgrades so you can focus on keeping Git backups fresh.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--promo-banner-height: 0px;
|
||||
--background: oklch(0.99 0 0);
|
||||
--foreground: oklch(0.15 0 0);
|
||||
--card: oklch(0.985 0 0);
|
||||
|
||||
Reference in New Issue
Block a user