Skip to main content

Overview

Modrinth uses different deployment strategies for its various components:
  • Frontend (Web): Cloudflare Pages with SSR
  • Backend (Labrinth): Docker containers on Kubernetes
  • Desktop App: GitHub Releases with auto-updater
  • Documentation: Cloudflare Pages (static)

Frontend Deployment (Cloudflare Pages)

Build Process

The web frontend is deployed to Cloudflare Pages with Nuxt’s Cloudflare preset. Build command:
NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build
Output: .output/ directory with:
  • Static assets in .output/public/
  • Server functions in .output/server/

CI/CD Workflow

.github/workflows/frontend-deploy.yml
name: Deploy Frontend

on:
  push:
    branches:
      - main
    paths:
      - 'apps/frontend/**'
      - 'packages/ui/**'
      - 'packages/api-client/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: pnpm/action-setup@v2
        with:
          version: 9.15.0
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      
      - name: Install dependencies
        run: pnpm install
      
      - name: Build
        run: pnpm pages:build
        env:
          NUXT_PUBLIC_LABRINTH_URL: https://api.modrinth.com
          NUXT_PUBLIC_ARCHON_URL: https://archon.modrinth.com
      
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: modrinth-web
          directory: apps/frontend/.output/public
          gitHubToken: ${{ secrets.GITHUB_TOKEN }}

Preview Deployments

Every pull request gets an automatic preview deployment:
.github/workflows/frontend-preview.yml
name: Preview Deployment

on:
  pull_request:
    paths:
      - 'apps/frontend/**'
      - 'packages/ui/**'

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      # ... same build steps ...
      
      - name: Deploy Preview
        uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: modrinth-web
          directory: apps/frontend/.output/public
          branch: pr-${{ github.event.pull_request.number }}
Preview URL: https://pr-{number}.modrinth-web.pages.dev

Environment Variables

Production environment variables are configured in Cloudflare Pages:
NUXT_PUBLIC_LABRINTH_URL=https://api.modrinth.com
NUXT_PUBLIC_ARCHON_URL=https://archon.modrinth.com
NUXT_PUBLIC_KYROS_URL=https://{node}.nodes.modrinth.com
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
RATE_LIMIT_KEY=...

SSR Configuration

Nuxt is configured for server-side rendering:
nuxt.config.ts
export default defineNuxtConfig({
	nitro: {
		preset: 'cloudflare-pages',
		compressPublicAssets: true,
	},
	routingRules: {
		'/api/**': { cache: { maxAge: 0 } },
		'/_nuxt/**': { cache: { maxAge: 31536000 } },
	},
})

Rollback

Rollback to previous deployment:
# Via Cloudflare dashboard
# Pages > modrinth-web > Deployments > Rollback

# Or redeploy previous commit
git revert HEAD
git push origin main

Backend Deployment (Docker + Kubernetes)

Docker Build

Labrinth is deployed as a Docker container. Dockerfile:
apps/labrinth/Dockerfile
FROM rust:1.90 as builder

WORKDIR /app
COPY . .

# Build with release profile
RUN cargo build -p labrinth --profile release-labrinth

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y \
    ca-certificates \
    libssl3 \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release-labrinth/labrinth /usr/local/bin/labrinth

EXPOSE 8000

CMD ["labrinth"]

CI/CD Workflow

.github/workflows/labrinth-docker.yml
name: Build Labrinth Docker Image

on:
  push:
    branches:
      - main
    paths:
      - 'apps/labrinth/**'
      - 'packages/ariadne/**'
      - 'packages/daedalus/**'
      - 'Cargo.toml'
      - 'Cargo.lock'

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
      
      - name: Login to GitHub Container Registry
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/modrinth/labrinth
          tags: |
            type=ref,event=branch
            type=sha,prefix={{branch}}-
            type=raw,value=latest,enable={{is_default_branch}}
      
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./apps/labrinth/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Release Profile

Production builds use optimized settings:
Cargo.toml
[profile.release-labrinth]
inherits = "release"
opt-level = "s"       # Optimize for size
strip = false         # Keep debug symbols for Sentry
lto = true            # Link-time optimization
panic = "unwind"      # Don't exit on panic (allow recovery)
codegen-units = 1     # Better optimization

Kubernetes Deployment

Deployment manifest:
k8s/labrinth-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: labrinth
  namespace: modrinth
spec:
  replicas: 3
  selector:
    matchLabels:
      app: labrinth
  template:
    metadata:
      labels:
        app: labrinth
    spec:
      containers:
      - name: labrinth
        image: ghcr.io/modrinth/labrinth:latest
        ports:
        - containerPort: 8000
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: labrinth-secrets
              key: database-url
        - name: REDIS_URL
          valueFrom:
            secretKeyRef:
              name: labrinth-secrets
              key: redis-url
        resources:
          requests:
            memory: "512Mi"
            cpu: "500m"
          limits:
            memory: "2Gi"
            cpu: "2000m"
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 5
          periodSeconds: 5
Service:
k8s/labrinth-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: labrinth
  namespace: modrinth
spec:
  selector:
    app: labrinth
  ports:
  - port: 80
    targetPort: 8000
  type: LoadBalancer

Database Migrations

Migrations are run before deploying new version:
# Connect to production database (read-only first!)
psql $DATABASE_URL

# Test migration in transaction
BEGIN;
\i migrations/XXXXXX_new_migration.sql
ROLLBACK;  -- or COMMIT if looks good

# Run migrations via SQLx
cargo sqlx migrate run --database-url $DATABASE_URL
Always test migrations on staging first! Never run untested migrations on production.

Rolling Updates

# Update image
kubectl set image deployment/labrinth \
  labrinth=ghcr.io/modrinth/labrinth:new-version \
  -n modrinth

# Monitor rollout
kubectl rollout status deployment/labrinth -n modrinth

# Rollback if needed
kubectl rollout undo deployment/labrinth -n modrinth

Health Checks

Labrinth exposes health endpoints:
src/routes/health.rs
use actix_web::{web, HttpResponse};

pub async fn health() -> HttpResponse {
    HttpResponse::Ok().json(json!({
        "status": "ok",
        "version": env!("CARGO_PKG_VERSION"),
    }))
}

pub async fn ready(
    pool: web::Data<PgPool>,
) -> HttpResponse {
    // Check database connection
    if sqlx::query("SELECT 1").execute(pool.get_ref()).await.is_ok() {
        HttpResponse::Ok().json(json!({ "status": "ready" }))
    } else {
        HttpResponse::ServiceUnavailable().json(json!({ "status": "not ready" }))
    }
}

Desktop App Deployment

Build Process

The desktop app is built for all platforms using GitHub Actions.
.github/workflows/theseus-build.yml
name: Build Desktop App

on:
  push:
    tags:
      - 'v*.*.*'

jobs:
  build:
    strategy:
      matrix:
        platform:
          - macos-latest
          - ubuntu-latest
          - windows-latest
    runs-on: ${{ matrix.platform }}
    steps:
      - uses: actions/checkout@v4
      
      - uses: dtolnay/rust-toolchain@stable
      
      - uses: pnpm/action-setup@v2
        with:
          version: 9.15.0
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      
      - name: Install dependencies
        run: pnpm install
      
      - name: Install platform dependencies (Linux)
        if: matrix.platform == 'ubuntu-latest'
        run: |
          sudo apt update
          sudo apt install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
      
      - name: Build app
        run: pnpm app:build
      
      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: app-${{ matrix.platform }}
          path: |
            apps/app/src-tauri/target/release/bundle/

Release Process

1

Create Tag

git tag v1.2.3
git push origin v1.2.3
This triggers the build workflow.
2

Build Artifacts

GitHub Actions builds for:
  • macOS: Universal binary (x64 + ARM64) .dmg
  • Windows: .msi installer and .exe portable
  • Linux: .deb, .AppImage
3

Create GitHub Release

gh release create v1.2.3 \
  --title "Modrinth App v1.2.3" \
  --notes "Release notes here" \
  apps/app/target/release/bundle/**/*
4

Update Auto-Updater

Tauri’s auto-updater automatically detects new releases from GitHub.

Version Numbering

The app version is set in packages/app-lib/Cargo.toml:
[package]
name = "theseus"
version = "1.2.3"
This is automatically updated by the build workflow using the git tag.

Code Signing

macOS:
# Sign with Apple Developer certificate
codesign --sign "Developer ID Application: Modrinth" \
  --options runtime \
  --entitlements entitlements.plist \
  Modrinth.app

# Notarize
xcrun notarytool submit Modrinth.dmg \
  --apple-id $APPLE_ID \
  --password $APP_PASSWORD \
  --team-id $TEAM_ID
Windows:
# Sign with certificate
signtool sign /f certificate.pfx \
  /p $PASSWORD \
  /tr http://timestamp.digicert.com \
  /td SHA256 \
  Modrinth-Setup.exe

Auto-Updates

The app checks for updates on launch:
apps/app/src/main.rs
use tauri_plugin_updater::UpdaterExt;

fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_updater::Builder::new().build())
        .setup(|app| {
            let handle = app.handle();
            tauri::async_runtime::spawn(async move {
                if let Ok(Some(update)) = handle.updater().check().await {
                    // Notify user
                    // Download and install
                    update.download_and_install().await.unwrap();
                }
            });
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Documentation Deployment

Documentation (this site) is deployed to Cloudflare Pages:
# Build docs
cd apps/docs
pnpm build

# Deploy
wrangler pages deploy dist --project-name modrinth-docs
Automatically deployed on push to main.

Infrastructure

Services

Modrinth production infrastructure:
  • Cloudflare Pages: Frontend hosting + CDN
  • Kubernetes: Backend API orchestration
  • PostgreSQL: Primary database (managed)
  • Redis: Cache and sessions (managed)
  • ClickHouse: Analytics database (managed)
  • Meilisearch: Search engine (managed)
  • S3: Object storage (files, images)
  • Sentry: Error tracking
  • Datadog: Monitoring and logging

Monitoring

Health checks:
# API health
curl https://api.modrinth.com/health

# Search health
curl https://api.modrinth.com/_internal/search/health
Metrics (Prometheus):
  • Request rate
  • Response time
  • Error rate
  • Database query time
  • Cache hit rate
Logging (Datadog):
  • Application logs
  • Access logs
  • Error logs
  • Audit logs

Scaling

Horizontal scaling:
# Scale API pods
kubectl scale deployment/labrinth --replicas=5 -n modrinth

# Auto-scaling
kubectl autoscale deployment/labrinth \
  --min=3 --max=10 \
  --cpu-percent=70 \
  -n modrinth
Database scaling:
  • Read replicas for read-heavy queries
  • Connection pooling (PgBouncer)
  • Query optimization

Deployment Checklist

Pre-Deployment

1

Run Tests

pnpm test
cargo test --workspace
2

Check Linting

pnpm lint
cargo clippy --workspace --all-targets -- -D warnings
3

Test Migrations

Test database migrations on staging environment first.
4

Update Changelog

Document changes in CHANGELOG.md or release notes.
5

Review Dependencies

Check for security vulnerabilities:
pnpm audit
cargo audit

Deployment

1

Deploy to Staging

Test on staging environment first:
git push origin staging
2

Run Smoke Tests

Verify critical functionality on staging.
3

Deploy to Production

git push origin main
4

Monitor Deployment

Watch logs and metrics for errors:
  • Check Sentry for new errors
  • Monitor Datadog dashboards
  • Watch Kubernetes pod status
5

Verify Functionality

Test critical user flows:
  • Login/authentication
  • Project search
  • File downloads
  • API endpoints

Post-Deployment

  • Announce release (if user-facing changes)
  • Update documentation if needed
  • Monitor for issues over next 24 hours
  • Be ready to rollback if critical issues arise

Rollback Procedures

Frontend Rollback

# Via Cloudflare Pages dashboard
# Or redeploy previous commit
git revert HEAD
git push origin main

Backend Rollback

# Kubernetes rollback
kubectl rollout undo deployment/labrinth -n modrinth

# Or deploy specific version
kubectl set image deployment/labrinth \
  labrinth=ghcr.io/modrinth/labrinth:previous-version \
  -n modrinth

Database Rollback

If migration needs to be reverted:
# Run down migration (if available)
cargo sqlx migrate revert --database-url $DATABASE_URL

# Or restore from backup
psql $DATABASE_URL < backup.sql
Database rollbacks are risky! Always test migrations thoroughly before deploying.

Security

Secrets Management

  • GitHub Secrets for CI/CD
  • Kubernetes Secrets for production
  • Never commit secrets to git
  • Rotate secrets regularly

Vulnerability Scanning

# npm audit
pnpm audit

# cargo audit
cargo install cargo-audit
cargo audit

# Container scanning (Docker)
docker scan ghcr.io/modrinth/labrinth:latest

SSL/TLS

  • Cloudflare provides SSL for frontend
  • Let’s Encrypt certificates for backend
  • Enforce HTTPS everywhere

Next Steps

Local Setup

Set up local development environment

Testing

Run tests before deployment

Backend (Labrinth)

Learn about backend architecture

Contributing

Contribute to Modrinth