diff --git a/.env.example b/.env.example
index aa43c6e..6b33689 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,8 @@
NODE_ENV=production
HOST=0.0.0.0
PORT=4321
+# Optional application base path (use "/" for root, or "/mirror" for subpath deployments)
+BASE_URL=/
# Database Configuration
# For self-hosted, SQLite is used by default
@@ -31,6 +33,12 @@ BETTER_AUTH_URL=http://localhost:4321
# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
#
+# If your app is served from a path prefix (e.g. https://git.example.com/mirror), set:
+# 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
+#
# BETTER_AUTH_URL - Used server-side for auth callbacks and redirects
# PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls
# BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests
diff --git a/Dockerfile b/Dockerfile
index f809791..0d51df4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,6 +16,7 @@ COPY . .
RUN bun run build
RUN mkdir -p dist/scripts && \
for script in scripts/*.ts; do \
+ if [ "$(basename "$script")" = "runtime-server.ts" ]; then continue; fi; \
bun build "$script" --target=bun --outfile=dist/scripts/$(basename "${script%.ts}.js"); \
done
@@ -59,6 +60,7 @@ COPY --from=pruner /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
+COPY --from=builder /app/scripts/runtime-server.ts ./scripts/runtime-server.ts
COPY --from=builder /app/drizzle ./drizzle
# Remove build-only packages that are not needed at runtime
@@ -73,6 +75,7 @@ ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
ENV DATABASE_URL=file:data/gitea-mirror.db
+ENV BASE_URL=/
# Create directories and setup permissions
RUN mkdir -p /app/certs && \
@@ -90,6 +93,6 @@ VOLUME /app/data
EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
- CMD wget --no-verbose --tries=1 --spider http://localhost:4321/api/health || exit 1
+ CMD sh -c 'BASE="${BASE_URL:-/}"; if [ "$BASE" = "/" ]; then BASE=""; else BASE="${BASE%/}"; fi; wget --no-verbose --tries=1 --spider "http://localhost:4321${BASE}/api/health" || exit 1'
ENTRYPOINT ["./docker-entrypoint.sh"]
diff --git a/README.md b/README.md
index 75276df..6660da4 100644
--- a/README.md
+++ b/README.md
@@ -300,7 +300,19 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
### Reverse Proxy Configuration
-If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues with JavaScript files not loading properly, try enabling HTTP/2 support in your proxy configuration. While not required by the application, some proxy configurations may have better compatibility with HTTP/2 enabled. See [issue #43](https://github.com/RayLabsHQ/gitea-mirror/issues/43) for reference.
+If you run behind a reverse proxy on a subpath (for example `https://git.example.com/mirror`), configure:
+
+```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
+```
+
+Notes:
+- `BASE_URL` sets the application path prefix.
+- `BETTER_AUTH_TRUSTED_ORIGINS` should contain origins only (no path).
+- `BASE_URL` is runtime configuration, so prebuilt registry images can be reused across different subpaths.
### Mirror Token Rotation (GitHub Token Changed)
diff --git a/docker-compose.alt.yml b/docker-compose.alt.yml
index 296bfba..90b83df 100644
--- a/docker-compose.alt.yml
+++ b/docker-compose.alt.yml
@@ -22,6 +22,7 @@ services:
# 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
+ # Path-prefix deployments (e.g. /mirror) are supported at runtime via BASE_URL.
# === CORE SETTINGS ===
# These are technically required but have working defaults
@@ -29,6 +30,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
+ - BASE_URL=${BASE_URL:-/}
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
# Optional concurrency controls (defaults match in-app defaults)
# If you want perfect ordering of issues and PRs, set these at 1
@@ -36,7 +38,11 @@ services:
- MIRROR_PULL_REQUEST_CONCURRENCY=${MIRROR_PULL_REQUEST_CONCURRENCY:-5}
healthcheck:
- test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
+ test:
+ [
+ "CMD-SHELL",
+ "BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
+ ]
interval: 30s
timeout: 10s
retries: 5
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index b7b97d5..8811b8f 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -66,6 +66,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
+ - BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=dev-secret-key
# GitHub/Gitea Mirror Config
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
@@ -89,7 +90,11 @@ services:
# Optional: Skip TLS verification (insecure, use only for testing)
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
healthcheck:
- test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/api/health"]
+ test:
+ [
+ "CMD-SHELL",
+ "BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=1 --spider \"http://localhost:4321$${BASE}/api/health\"",
+ ]
interval: 30s
timeout: 5s
retries: 3
diff --git a/docker-compose.yml b/docker-compose.yml
index c278b44..1c647dc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -30,6 +30,7 @@ services:
- DATABASE_URL=file:data/gitea-mirror.db
- HOST=0.0.0.0
- PORT=4321
+ - BASE_URL=${BASE_URL:-/}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
# REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik),
@@ -37,6 +38,11 @@ services:
# 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
+ # If deployed under a path prefix (e.g. https://git.example.com/mirror), also set:
+ # 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
- PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-}
# Optional: ENCRYPTION_SECRET will be auto-generated if not provided
@@ -81,7 +87,11 @@ services:
- HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false}
- HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
healthcheck:
- test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
+ test:
+ [
+ "CMD-SHELL",
+ "BASE=\"${BASE_URL:-/}\"; if [ \"$${BASE}\" = \"/\" ]; then BASE=\"\"; else BASE=\"$${BASE%/}\"; fi; wget --no-verbose --tries=3 --spider \"http://localhost:4321$${BASE}/api/health\"",
+ ]
interval: 30s
timeout: 10s
retries: 5
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index 5698269..50e24f0 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -229,7 +229,13 @@ trap 'shutdown_handler' TERM INT HUP
# Start the application
echo "Starting Gitea Mirror..."
-bun ./dist/server/entry.mjs &
+if [ -f "./scripts/runtime-server.ts" ]; then
+ bun ./scripts/runtime-server.ts &
+elif [ -f "./dist/scripts/runtime-server.js" ]; then
+ bun ./dist/scripts/runtime-server.js &
+else
+ bun ./dist/server/entry.mjs &
+fi
APP_PID=$!
# Wait for the application to finish
diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md
index 6a83963..533b218 100644
--- a/docs/ENVIRONMENT_VARIABLES.md
+++ b/docs/ENVIRONMENT_VARIABLES.md
@@ -33,6 +33,7 @@ Essential application settings required for running Gitea Mirror.
| `NODE_ENV` | Application environment | `production` | No |
| `HOST` | Server host binding | `0.0.0.0` | No |
| `PORT` | Server port | `4321` | No |
+| `BASE_URL` | Application base path. Use `/` for root deployments, or a prefix such as `/mirror` when serving behind a reverse-proxy path prefix. | `/` | No |
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No |
| `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes |
| `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No |
@@ -302,6 +303,7 @@ services:
environment:
# Core Configuration
- NODE_ENV=production
+ - BASE_URL=/
- DATABASE_URL=file:data/gitea-mirror.db
- BETTER_AUTH_SECRET=your-secure-secret-here
# Primary access URL:
@@ -370,6 +372,21 @@ This setup allows you to:
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
+### Path Prefix Deployments
+
+If you serve Gitea Mirror under a subpath such as `https://git.example.com/mirror`, set:
+
+```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
+```
+
+Notes:
+- `BETTER_AUTH_TRUSTED_ORIGINS` must contain origins only (no path).
+- `BASE_URL` is applied at runtime, so prebuilt images can be reused with different path prefixes.
+
### Trusted Origins
The `BETTER_AUTH_TRUSTED_ORIGINS` variable serves multiple purposes:
diff --git a/package.json b/package.json
index e40bb78..a82d263 100644
--- a/package.json
+++ b/package.json
@@ -31,8 +31,8 @@
"test-shutdown": "bun scripts/test-graceful-shutdown.ts",
"test-shutdown-cleanup": "bun scripts/test-graceful-shutdown.ts --cleanup",
"preview": "bunx --bun astro preview",
- "start": "bun dist/server/entry.mjs",
- "start:fresh": "bun run cleanup-db && bun run manage-db init && bun dist/server/entry.mjs",
+ "start": "bun scripts/runtime-server.ts",
+ "start:fresh": "bun run cleanup-db && bun run manage-db init && bun scripts/runtime-server.ts",
"test": "bun test",
"test:migrations": "bun scripts/validate-migrations.ts",
"test:watch": "bun test --watch",
diff --git a/scripts/runtime-server.ts b/scripts/runtime-server.ts
new file mode 100644
index 0000000..4fa5cc2
--- /dev/null
+++ b/scripts/runtime-server.ts
@@ -0,0 +1,76 @@
+import { createServer } from "node:http";
+import type { IncomingMessage, ServerResponse } from "node:http";
+
+function normalizeBasePath(basePath: string | undefined): string {
+ if (!basePath || !basePath.trim()) {
+ return "/";
+ }
+
+ let normalized = basePath.trim();
+ if (!normalized.startsWith("/")) {
+ normalized = `/${normalized}`;
+ }
+
+ normalized = normalized.replace(/\/+$/, "");
+ return normalized || "/";
+}
+
+function rewriteRequestUrl(rawUrl: string, basePath: string): string | null {
+ if (basePath === "/") {
+ return rawUrl;
+ }
+
+ const url = new URL(rawUrl, "http://localhost");
+ const pathname = url.pathname;
+
+ if (pathname === basePath || pathname === `${basePath}/`) {
+ url.pathname = "/";
+ return `${url.pathname}${url.search}`;
+ }
+
+ if (pathname.startsWith(`${basePath}/`)) {
+ url.pathname = pathname.slice(basePath.length) || "/";
+ return `${url.pathname}${url.search}`;
+ }
+
+ return null;
+}
+
+const basePath = normalizeBasePath(process.env.BASE_URL);
+const host = process.env.HOST || "0.0.0.0";
+const port = Number.parseInt(process.env.PORT || "4321", 10);
+
+process.env.ASTRO_NODE_AUTOSTART = "disabled";
+const { handler } = await import("../dist/server/entry.mjs");
+
+const server = createServer((req: IncomingMessage, res: ServerResponse) => {
+ if (!req.url) {
+ res.statusCode = 400;
+ res.end("Bad Request");
+ return;
+ }
+
+ const rewrittenUrl = rewriteRequestUrl(req.url, basePath);
+ if (rewrittenUrl === null) {
+ res.statusCode = 404;
+ res.end("Not Found");
+ return;
+ }
+
+ req.url = rewrittenUrl;
+ req.headers["x-gitea-mirror-base-rewritten"] = "1";
+
+ Promise.resolve((handler as unknown as (request: IncomingMessage, response: ServerResponse) => unknown)(req, res)).catch((error) => {
+ console.error("Unhandled runtime server error:", error);
+ if (!res.headersSent) {
+ res.statusCode = 500;
+ res.end("Internal Server Error");
+ } else {
+ res.end();
+ }
+ });
+});
+
+server.listen(port, host, () => {
+ console.log(`Runtime server listening on http://${host}:${port} (BASE_URL=${basePath})`);
+});
diff --git a/src/components/NotFound.tsx b/src/components/NotFound.tsx
index 8dee1cb..4e63ea2 100644
--- a/src/components/NotFound.tsx
+++ b/src/components/NotFound.tsx
@@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
+import { withBase } from "@/lib/base-path";
export function NotFound() {
return (
@@ -21,7 +22,7 @@ export function NotFound() {
{/* Action Buttons */}