Skip to content

Repository files navigation

Port-Au-Next

Port-Au-Next Logo

A no-downtime multi-tenant Next.js self-host deployment manager

Overview

Port-Au-Next allows you to self-host multiple Next.js applications using Docker containers. It uses blue/green deployments to update your apps without any downtime - when a new version is ready, traffic switches over seamlessly. You maintain full control of your infrastructure without being locked into cloud platforms.

Whether you're deploying to a VPS, cloud server, or hardware in your own environment, Port-Au-Next provides an elegant solution for managing your Next.js application fleet with secure user authentication and powerful preview branch capabilities.

Key Features

  • Blue/Green Deployments: Seamless deployments with zero downtime using a true blue/green strategy
  • Multi-Tenancy: Host multiple Next.js applications on a single server
  • Domain Management: Map domains to apps; connect Cloudflare to automate tunnel routes and DNS from the dashboard
  • Cloudflare Tunnel Integration: Connect your account, pick a tunnel, sync published applications and proxied CNAME records per app
  • Preview Branches: Deploy and test feature branches with isolated environments and custom subdomains
  • User Authentication: Secure admin interface with user management and authentication
  • GitHub Actions Integration: Automatically deploy when pushing to configured branches
  • Health Checks: Intelligent service switching only when new deployments are verified healthy
  • Environment Isolation: Each app, branch, or preview deployment can have its own environment variables
  • Customizable Build Process: Use the default optimized Dockerfile or create your own
  • Shared Infrastructure: PostgreSQL, Redis, imgproxy, port-schedule (HTTP cron / webhook scheduler), and Umami (opt-in analytics) available to all applications
  • Web-Based Management UI: Monitor and control your deployments through an intuitive interface

Architecture

Port-Au-Next uses a Docker-based microservices architecture with the following components:

  1. Nginx Reverse Proxy: Routes traffic to the correct application containers and preview branch deployments
  2. Deployment Manager: Web UI and API for managing applications and deployments, with secure authentication
  3. Authentication Layer: Handles user authentication and session management
  4. Preview Branch Manager: Manages isolated preview environments for feature branches
  5. Shared Services: PostgreSQL, Redis, imgproxy, port-schedule (per-app API keys), and Umami (opt-in per-app analytics with dashboard login)
  6. Application Containers: Isolated containers for each application version and preview branch

Quick Start

Prerequisites

  • Docker and Docker Compose
  • Git
  • SSH key for GitHub authentication (for automatic deployments)

Installation

  1. Clone the repository:
git clone https://github.com/cfpg/port-au-next.git
cd port-au-next
  1. Create a .env file with required variables, you can copy .env.example as a starter:
# Deployment Manager
DEPLOYMENT_MANAGER_HOST=domain.to.access.manager.com
DEPLOYMENT_MANAGER_AUTH_EMAIL=changeto@yourdomain.com
DEPLOYMENT_MANAGER_AUTH_PASSWORD=changeme098
BETTER_AUTH_SECRET=changeme567

# Shared Postgres DB superuser credentials
POSTGRES_USER=portaunext
POSTGRES_PASSWORD=changeme123
POSTGRES_DB=portaunext

# Image Optimization
IMGPROXY_HOST=cdn.yourdomain.com

# Minio Configuration
MINIO_HOST=storage.yourdomain.com
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin

# port-schedule (shared HTTP scheduler; see “HTTP scheduling” below)
PORT_SCHEDULE_MASTER_API_KEY=generate_a_long_random_secret
PORT_SCHEDULE_MIGRATE_ON_START=false
PORT_SCHEDULE_HOST_PORT=8085
# Optional: public hostname for nginx → port-schedule (see .env.example)
# PORT_SCHEDULE_HOST=schedule.yourdomain.com

# Umami analytics (see “Umami analytics” below)
UMAMI_HOST=analytics.yourdomain.com
# Generate: openssl rand -hex 32
UMAMI_APP_SECRET=your_64_char_hex_secret_here
UMAMI_DB_USER=umami
UMAMI_DB_PASSWORD=changeme_umami_db
UMAMI_ADMIN_USERNAME=admin
UMAMI_ADMIN_PASSWORD=your_chosen_admin_password

# Optional: Cloudflare (prefer Settings → Cloudflare in the deployment manager UI)
# CLOUDFLARE_API_KEY=your_scoped_api_token
# CLOUDFLARE_ACCOUNT_ID=your_account_id
  1. Launch the system:
docker compose up --build -d
  1. Access the deployment manager UI at http://localhost:80 or using the DEPLOYMENT_MANAGER_HOST you configured in the .env file and log in with the configured admin credentials

Cloudflare Tunnels

Port-Au-Next integrates with your Cloudflare account to manage tunnel published applications and proxied CNAME DNS from the dashboard. You still add domains to Cloudflare and run cloudflared on your machine — those steps stay manual.

What Port-Au-Next automates

  • List tunnels in your account (name, status, replicas, route count)
  • Select an existing tunnel or create a new one
  • Create or update published application routes when you assign an app domain
  • Sync platform service routes from root .env *_HOST variables (deployment manager, MinIO, imgproxy, port-schedule, Umami when set)
  • Create proxied CNAME records pointing hostnames at your tunnel
  • Wildcard preview routes when preview branches are enabled
  • Cache purge on deploy (when a zone is linked to the app)

What you do manually

  1. Add your domain to Cloudflare and point nameservers at your registrar
  2. Run cloudflared service install <token> on your homelab (token shown in Settings → Cloudflare)

Global setup — Settings → Cloudflare

  1. Create a scoped API token with:
    • Account → Cloudflare Tunnel → Edit
    • Account → Account Settings → Read
    • Zone → DNS → Edit
    • Zone → Zone → Read
  2. Paste Account ID and API token in the deployment manager (in-app instructions included)
  3. Select an existing tunnel (e.g. one already routing your homelab) or create a new one
  4. Install the tunnel connector on your machine using the displayed token

Optional legacy env fallback: CLOUDFLARE_API_KEY + CLOUDFLARE_ACCOUNT_ID in .env (UI connection is preferred).

Per-app — App Settings → Cloudflare

Each app shows tunnel route status for its domain:

Status Meaning
Synced Route and DNS managed by Port-Au-Next
External Route exists on the tunnel from manual/legacy setup
Missing route Domain set but no ingress on the selected tunnel
Missing DNS Route exists but CNAME is not configured

Use Sync route to adopt existing apps without changing the domain string — useful when routes were created manually before connecting Cloudflare.

Assigning or changing a domain in App Settings also triggers route + DNS sync automatically when Cloudflare is connected.

Platform services — Settings → Cloudflare

Root .env public hostnames are synced to the selected tunnel automatically on deployment-manager startup (and when you select a tunnel):

Env var Service
DEPLOYMENT_MANAGER_HOST Deployment manager UI
IMGPROXY_HOST imgproxy CDN
MINIO_HOST MinIO object storage
PORT_SCHEDULE_HOST port-schedule (optional)
UMAMI_HOST Umami analytics (optional — synced when set)
BUGSINK_HOST Bugsink error tracking (optional — synced when set)

Use Sync all platform services or per-service Sync route in Settings → Cloudflare. After changing any *_HOST value in .env, restart the deployment-manager container.

Preview branches

When preview branches are enabled and a preview domain is saved, Port-Au-Next creates a wildcard published application (e.g. *.preview.yourdomain.com) and proxied CNAME on the selected tunnel.

Deployment Workflow

Port-Au-Next implements a blue/green deployment strategy:

  1. Preparation: A new deployment is initiated via the UI or GitHub webhook
  2. Building: The latest code is pulled and built into a new Docker image
  3. Launching: A new container is started with the updated code and assigned a version
  4. Health Check: The new container is verified as healthy
  5. Switching: Nginx configuration is updated to route traffic to the new container
  6. Cleanup: The previous container is gracefully terminated

This approach ensures your applications remain available throughout the entire deployment process.

Preview Branches

Preview branches allow you to deploy and test feature branches in isolated environments before merging to production:

  1. Setup: Enable preview branches for an application and configure a preview domain (e.g. preview.yourdomain.com)
    • With Cloudflare connected, Port-Au-Next creates the wildcard tunnel route and proxied CNAME automatically
    • Without Cloudflare, add DNS for *.preview.yourdomain.com yourself
  2. Deployment: Deploy any branch to get an isolated environment with:
    • Unique subdomain (e.g., feature-branch.preview.yourdomain.com)
    • Isolated database
    • Global preview and branch-specific environment variables, allows you to point your preview environments to DEV services and use test new variables per branch
  3. Testing: Test your changes in a production-like environment
  4. Cleanup: Automatically or manually clean up preview environments when no longer needed

Preview Branch Management

  • Preview branches can be enabled/disabled per application
  • Each preview deployment gets its own database and environment
  • Environment variables can be set specifically for preview deployments
  • Automatic cleanup options available for merged/deleted branches

Configuration

Adding a New Application

  1. From the deployment manager UI, create a new application
  2. Provide the Git repository URL and branch to deploy
  3. Configure domain settings and environment variables (domain syncs Cloudflare tunnel route + DNS when connected)
  4. Initiate the first deployment

Cloudflare per app

  1. Connect Cloudflare globally under Settings → Cloudflare
  2. Open App Settings → Cloudflare for route status, zone info, and Sync route
  3. Existing apps with manual tunnel routes show as External until you sync

Enabling Preview Branches

  1. Navigate to your application's settings
  2. Configure a preview domain (e.g. preview.yourdomain.com)
  3. Enable the preview branches feature (wildcard tunnel route + DNS when Cloudflare is connected)
  4. Configure default preview environment variables (optional)

User Management

  1. Access the user management section from settings
  2. Add new users with appropriate permissions
  3. Manage user access and passwords
  4. Configure authentication settings

Custom Dockerfile

Port-Au-Next will use a repository's Dockerfile if present. Otherwise, it creates an optimized Dockerfile configured for Next.js applications with:

  • Multi-stage build process
  • Proper caching of dependencies
  • Production-optimized settings
  • Non-root user execution

Uses Prisma

In Settings → Database, enable Uses Prisma for apps that use Prisma ORM:

  • CREATEDB is granted on the app's production PostgreSQL user (for shadow databases during local/CLI migrations).
  • If the repo has no Dockerfile, Port-Au-Next writes a platform-managed Dockerfile tagged with # generated-by-port-au-next v1 and optional -uses_prisma. The Prisma template uses Node 24, runs npx prisma generate before next build (no database connection during build), and copies schema plus generated client output into the runtime image.
  • Toggling Uses Prisma updates the generated Dockerfile on the next deployment (marker version/flags must match the feature). Do not commit the # generated-by-port-au-next line unless you accept the platform overwriting that file on deploy.
  • Custom Dockerfile: If your repo includes its own Dockerfile, the platform never modifies it; configure Prisma yourself.
  • .dockerignore: Do not exclude prisma/, prisma.config.ts, or your Prisma client output paths (generated/, src/generated/, etc.).

Prisma migrations and expand/contract

Port-Au-Next can run prisma migrate deploy during deploy (Settings → Database → Run migrations on deploy, under Uses Prisma). The pipeline is:

  1. Build images and start the new (green) container.
  2. Preflight — confirm the container is running (no traffic switch yet).
  3. Migrate — one-off container from {app}:{version}-migrate logs prisma migrate status, then runs prisma migrate deploy (only deploy gates success).
  4. Switch — nginx routes traffic to the new container.

Migrations run against the live database for that app (or preview branch). While the previous version may still be serving traffic, the database schema may already be updated. You must use expand/contract migrations:

Phase What to do Example
Expand Add new schema in a way old code still works Add a nullable column or new table
Deploy Ship app code that uses the new schema Enable feature flags if needed
Contract Remove old schema in a later deploy Drop deprecated column after nothing reads it

Do not rely on rolling back the app to undo schema changes — rollback traffic only; the database keeps migrated state.

Requirements for auto-migrate:

  • Committed prisma/migrations/ (if missing, migrate is skipped with a warning).
  • prisma CLI available in a Docker migrator stage. Platform-generated Prisma Dockerfiles include this stage automatically. Custom Dockerfiles must define migrator (copy node_modules, prisma/, package.json, and prisma.config.ts when used) or the migrator image build will fail.
  • Environment variables (DATABASE_URL, etc.) set in the deployment manager; Prisma 7 apps use prisma.config.ts to read them — configure that file in your repo.

Local workflow: npx prisma migrate dev → commit migration SQL → deploy with auto-migrate enabled.

Environment Variables

Environment variables can be configured:

  • Per application (base configuration)
  • Per branch within an application (branch-specific overrides)
  • Per preview deployment (preview-specific settings)

This flexibility enables managing multiple environments (development, staging, production) within the same Port-Au-Next instance.

HTTP scheduling (port-schedule)

port-schedule is a first-party service that stores per-app cron-like jobs and, on a fixed interval, performs outbound HTTP requests to URLs you control (your Next.js app’s public routes). It uses the same PostgreSQL database as Port-Au-Next (port_schedule schema). There is no OS crontab inside app containers; scheduling is entirely API-driven.

How apps get access

On each production deployment, the deployment manager:

  1. Ensures a tenant row exists in port_schedule.tenants for your app (apps.id).
  2. Stores the plaintext client API key in app_services (service_type = port_schedule), same idea as MinIO credentials.
  3. Injects into the running app container:
Variable Purpose
PORT_SCHEDULE_URL Base URL of the scheduler from inside Docker (fixed: http://port-schedule:8080).
PORT_SCHEDULE_API_KEY Secret used as Authorization: Bearer … when your app calls the scheduler API.

Preview branch deployments do not receive these variables in the current version; only the main production app deployment does.

Never expose PORT_SCHEDULE_API_KEY to the browser (do not prefix it with NEXT_PUBLIC_). Use it only in server code (Route Handlers, Server Actions, instrumentation.ts, scripts).

The deployment manager uses a separate PORT_SCHEDULE_MASTER_API_KEY (shared with the port-schedule container) only to call admin routes such as PUT /admin/apps/:appId/credentials. That master key is not injected into app containers.

Tenant API (what your Next.js app calls)

All tenant routes are under /v1 and require:

Authorization: Bearer <PORT_SCHEDULE_API_KEY>
Content-Type: application/json

Examples:

  • GET /v1/jobs — list active jobs (soft-deleted jobs are omitted).
  • POST /v1/jobs — create a job (body includes name, cron_expression, timezone, http_method, url, optional body, headers_json, webhook_secret, enabled).
  • GET /v1/jobs/:jobId/runs — paginated run history for a job.

Job names are unique per app among non-deleted jobs. Use a stable name (e.g. nightly-sync) so you can safely “ensure exists” on startup.

Cron uses a six-field expression (second minute hour day month weekday) with 10-second granularity on the scheduler tick. Use an IANA timezone string (e.g. America/Mexico_City) per job.

Webhook url must satisfy the service’s public URL policy (typically https:// to your real app hostname as seen from the internet—not localhost, Docker service names, or raw IPs). Use your public site URL and a dedicated path (e.g. https://yourdomain.com/api/cron/nightly).

When a job defines webhook_secret, every outbound request from port-schedule includes:

X-PortAuNext-Schedule: <webhook_secret>

Your route should compare this value to a secret you also store in app env (see example below). This is independent of the Bearer API key used to talk to port-schedule.

Next.js: ensure jobs once on server startup (instrumentation.ts)

Use root instrumentation.ts (or src/instrumentation.ts) and export register(). Run scheduler setup only in the Node runtime (not Edge). Depending on your Next.js version, you may need experimental.instrumentationHook: true in next.config.js—check the docs for your release.

Example pattern: list jobs, find by stable name, create if missing.

// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME !== 'nodejs') return;

  const base = process.env.PORT_SCHEDULE_URL;
  const token = process.env.PORT_SCHEDULE_API_KEY;
  if (!base || !token) return;

  const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
  const site = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, '');
  const webhookSecret = process.env.CRON_WEBHOOK_SECRET;
  if (!site || !webhookSecret) {
    console.warn('port-schedule: skip job ensure (NEXT_PUBLIC_SITE_URL or CRON_WEBHOOK_SECRET unset)');
    return;
  }

  const jobName = 'nightly-sync';
  const listRes = await fetch(`${base}/v1/jobs?limit=200`, { headers: auth });
  if (!listRes.ok) {
    console.error('port-schedule: list jobs failed', await listRes.text());
    return;
  }
  const { jobs } = (await listRes.json()) as { jobs: { name: string }[] };
  if (jobs.some((j) => j.name === jobName)) return;

  const body = {
    name: jobName,
    cron_expression: '0 0 2 * * *',
    timezone: 'UTC',
    http_method: 'POST',
    url: `${site}/api/cron/nightly`,
    webhook_secret: webhookSecret,
    enabled: true,
  };

  const createRes = await fetch(`${base}/v1/jobs`, { method: 'POST', headers: auth, body: JSON.stringify(body) });
  if (!createRes.ok) {
    console.error('port-schedule: create job failed', await createRes.text());
  }
}

Add CRON_WEBHOOK_SECRET to your app’s environment in the deployment manager (a long random string). It must match the webhook_secret you register on the job (here the same variable is passed in the POST body). In development, register() may run more than once (restarts/HMR); relying on a unique job name keeps the logic idempotent.

Next.js: webhook Route Handler and verifying X-PortAuNext-Schedule

Implement a public POST route that port-schedule can call. Verify the header using a timing-safe compare so secrets are not leaked via short-circuiting.

// app/api/cron/nightly/route.ts
import { timingSafeEqual } from 'crypto';

export async function POST(request: Request) {
  const expected = process.env.CRON_WEBHOOK_SECRET;
  const header = request.headers.get('x-portau-next-schedule') ?? '';
  if (!expected || header.length !== expected.length) {
    return new Response('Unauthorized', { status: 401 });
  }
  const a = Buffer.from(header, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  if (!timingSafeEqual(a, b)) {
    return new Response('Unauthorized', { status: 401 });
  }

  // … your scheduled work …
  return Response.json({ ok: true });
}

Use the same secret value in the job’s webhook_secret field and in CRON_WEBHOOK_SECRET for the app.

Public nginx hostname (optional)

If you set PORT_SCHEDULE_HOST in the root .env, the deployment manager writes an nginx vhost so you can reach port-schedule via that hostname. App containers still use the fixed internal PORT_SCHEDULE_URL for API calls.

Further detail

For the full contract (soft delete, undelete routes, admin API, URL policy), see the statement of work in .plans/CUSTOM_HTTP_SCHEDULER_PLAN.md in this repository (if present in your checkout).

Umami analytics

Umami is a shared, privacy-focused analytics instance. Each app can opt in from the deployment manager (App Settings → Analytics). When enabled, the platform provisions an isolated Umami team, website, and dashboard login for that app.

Platform setup

  1. Generate UMAMI_APP_SECRET (session encryption key for Umami; at least 32 characters):
openssl rand -hex 32

Copy the output into .env as UMAMI_APP_SECRET=….

  1. Set UMAMI_HOST, UMAMI_APP_SECRET, UMAMI_DB_*, and UMAMI_ADMIN_* in the root .env (see .env.example). The deployment manager syncs UMAMI_ADMIN_* to Umami on startup, replacing the default admin / umami credentials on first boot.
  2. docker compose up -d umami (or bring up the full stack).
  3. Log in at https://{UMAMI_HOST} with the credentials from UMAMI_ADMIN_* in your .env.

The deployment manager creates the umami database on shared Postgres and writes an nginx vhost for UMAMI_HOST. Image: ghcr.io/umami-software/umami:postgresql-v2.18 (pin updated in docker-compose.yml).

Per-app behavior

Action Behavior
Enable Creates Umami team + view-only dashboard user + website for app.domain; stores credentials in app_services.
Production deploy Injects NEXT_PUBLIC_UMAMI_HOST and NEXT_PUBLIC_UMAMI_WEBSITE_ID when enabled.
Disable Stops env injection; Umami data and login are retained. Redeploy required (NEXT_PUBLIC_* are build-time).
Domain change Updates the Umami website domain automatically when app settings change.

Preview deployments do not receive Umami env vars. Add the tracking snippet in your Next.js app yourself (see the Analytics card in the UI). Cookie/consent banners are the app owner’s responsibility.

Bugsink error tracking

Bugsink is a shared, self-hosted error tracker (Sentry-SDK compatible). Each app can opt in from the deployment manager (App Settings → Error tracking). When enabled, the platform provisions an isolated Bugsink team and project with a DSN for that app.

Platform setup

  1. Generate BUGSINK_SECRET_KEY:
openssl rand -base64 50

Copy the output into .env as BUGSINK_SECRET_KEY=… (no django-insecure prefix).

  1. Set BUGSINK_HOST, BUGSINK_SECRET_KEY, BUGSINK_DB_*, and BUGSINK_ADMIN_* in the root .env (see .env.example). BUGSINK_ADMIN_* is used for CREATE_SUPERUSER on first boot only.

  2. docker compose up -d bugsink deployment-manager (or bring up the full stack).

  3. On startup, deployment-manager bootstraps a Bugsink API token automatically (bugsink-manage create_auth_token), encrypts it, and stores it in the platform_service_secrets table. No manual step required.

    Optional: set BUGSINK_API_TOKEN in .env to override the stored token (useful for recovery or pinning a known token).

  4. Log in at https://{BUGSINK_HOST} with BUGSINK_ADMIN_EMAIL / BUGSINK_ADMIN_PASSWORD.

The deployment manager creates the bugsink database on shared Postgres and writes an nginx vhost for BUGSINK_HOST. Image: bugsink/bugsink:2.

Per-app behavior

Action Behavior
Enable Creates Bugsink team + project; stores project ID, team ID, slug, and DSN in app_services.
Production deploy Injects SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN, and SENTRY_ENVIRONMENT when enabled.
Disable Stops env injection; Bugsink project and DSN are retained. Redeploy required.
App rename Updates the Bugsink project name when app settings change.

Preview deployments do not receive Bugsink env vars. Install @sentry/nextjs in your app (see the Error tracking card in the UI). Per-app Bugsink dashboard logins are not available via the API yet — use the platform admin to view errors.

API Reference

Port-Au-Next exposes a REST API for programmatic control. Here are key endpoints:

Applications

  • POST /api/:app/deploy: Trigger a deployment for an application
  • GET /api/apps: List all registered applications
  • GET /api/apps/:name/deployments: List deployments for a specific application
  • GET /api/apps/deployments/:appName/:deploymentId/logs/deploy: Deployment pipeline logs (JSON)
  • GET /api/apps/deployments/:appName/:deploymentId/logs/access: Tail nginx access log (?bytes=65536, max 256 KiB)
  • GET /api/apps/deployments/:appName/:deploymentId/logs/error: Tail nginx error log
  • GET /api/apps/deployments/:appName/:deploymentId/logs/build: Tail docker build log

Preview Branches

  • POST /api/apps/:appId/preview-branches: Enable preview branches for an app
  • POST /api/apps/:appId/preview-branches/:branch/deploy: Deploy a preview branch
  • DELETE /api/apps/:appId/preview-branches/:branch: Delete a preview branch
  • GET /api/apps/:appId/preview-branches: List active preview branches

Authentication

  • POST /api/auth/login: Authenticate user and get session
  • POST /api/auth/logout: End current session
  • POST /api/auth/password: Change user password
  • GET /api/auth/session: Get current session info

Monitoring and Troubleshooting

The deployment manager provides:

  • Deployment status tracking
  • Per-deployment deploy, access, and error logs in the UI
  • Automatic log cleanup 90 days after a deployment becomes inactive or failed (LOG_RETENTION_DAYS)
  • Health metrics
  • Preview branch status monitoring
  • Rollback capability for failed deployments

Security Considerations

  • All services run in an isolated Docker network
  • SSH keys for repository access are mounted read-only
  • Database credentials are managed securely
  • Docker socket access is restricted to the deployment manager
  • Secure session management for authenticated users
  • Preview branch environments are fully isolated
  • Environment variables are securely stored and managed
  • Regular security updates available through Docker images

License

Port-Au-Next is released under the MIT License. See LICENSE for details.


Port-Au-Next: Deploy Next.js applications on your terms

About

No-downtime multi-tenant Next.JS hosting & deploy manager using Docker Orchestration

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages