A no-downtime multi-tenant Next.js self-host deployment manager
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.
- 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
Port-Au-Next uses a Docker-based microservices architecture with the following components:
- Nginx Reverse Proxy: Routes traffic to the correct application containers and preview branch deployments
- Deployment Manager: Web UI and API for managing applications and deployments, with secure authentication
- Authentication Layer: Handles user authentication and session management
- Preview Branch Manager: Manages isolated preview environments for feature branches
- Shared Services: PostgreSQL, Redis, imgproxy, port-schedule (per-app API keys), and Umami (opt-in per-app analytics with dashboard login)
- Application Containers: Isolated containers for each application version and preview branch
- Docker and Docker Compose
- Git
- SSH key for GitHub authentication (for automatic deployments)
- Clone the repository:
git clone https://github.com/cfpg/port-au-next.git
cd port-au-next- Create a
.envfile with required variables, you can copy.env.exampleas 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- Launch the system:
docker compose up --build -d- Access the deployment manager UI at
http://localhost:80or using theDEPLOYMENT_MANAGER_HOSTyou configured in the.envfile and log in with the configured admin credentials
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.
- 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*_HOSTvariables (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)
- Add your domain to Cloudflare and point nameservers at your registrar
- Run
cloudflared service install <token>on your homelab (token shown in Settings → Cloudflare)
- Create a scoped API token with:
- Account → Cloudflare Tunnel → Edit
- Account → Account Settings → Read
- Zone → DNS → Edit
- Zone → Zone → Read
- Paste Account ID and API token in the deployment manager (in-app instructions included)
- Select an existing tunnel (e.g. one already routing your homelab) or create a new one
- 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).
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.
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.
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.
Port-Au-Next implements a blue/green deployment strategy:
- Preparation: A new deployment is initiated via the UI or GitHub webhook
- Building: The latest code is pulled and built into a new Docker image
- Launching: A new container is started with the updated code and assigned a version
- Health Check: The new container is verified as healthy
- Switching: Nginx configuration is updated to route traffic to the new container
- Cleanup: The previous container is gracefully terminated
This approach ensures your applications remain available throughout the entire deployment process.
Preview branches allow you to deploy and test feature branches in isolated environments before merging to production:
- 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.comyourself
- 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
- Unique subdomain (e.g.,
- Testing: Test your changes in a production-like environment
- Cleanup: Automatically or manually clean up preview environments when no longer needed
- 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
- From the deployment manager UI, create a new application
- Provide the Git repository URL and branch to deploy
- Configure domain settings and environment variables (domain syncs Cloudflare tunnel route + DNS when connected)
- Initiate the first deployment
- Connect Cloudflare globally under Settings → Cloudflare
- Open App Settings → Cloudflare for route status, zone info, and Sync route
- Existing apps with manual tunnel routes show as External until you sync
- Navigate to your application's settings
- Configure a preview domain (e.g.
preview.yourdomain.com) - Enable the preview branches feature (wildcard tunnel route + DNS when Cloudflare is connected)
- Configure default preview environment variables (optional)
- Access the user management section from settings
- Add new users with appropriate permissions
- Manage user access and passwords
- Configure authentication settings
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
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 v1and optional-uses_prisma. The Prisma template uses Node 24, runsnpx prisma generatebeforenext 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-nextline 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 excludeprisma/,prisma.config.ts, or your Prisma client output paths (generated/,src/generated/, etc.).
Port-Au-Next can run prisma migrate deploy during deploy (Settings → Database → Run migrations on deploy, under Uses Prisma). The pipeline is:
- Build images and start the new (green) container.
- Preflight — confirm the container is running (no traffic switch yet).
- Migrate — one-off container from
{app}:{version}-migratelogsprisma migrate status, then runsprisma migrate deploy(only deploy gates success). - 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). prismaCLI available in a Dockermigratorstage. Platform-generated Prisma Dockerfiles include this stage automatically. Custom Dockerfiles must definemigrator(copynode_modules,prisma/,package.json, andprisma.config.tswhen used) or the migrator image build will fail.- Environment variables (
DATABASE_URL, etc.) set in the deployment manager; Prisma 7 apps useprisma.config.tsto read them — configure that file in your repo.
Local workflow: npx prisma migrate dev → commit migration SQL → deploy with auto-migrate enabled.
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.
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.
On each production deployment, the deployment manager:
- Ensures a tenant row exists in
port_schedule.tenantsfor your app (apps.id). - Stores the plaintext client API key in
app_services(service_type = port_schedule), same idea as MinIO credentials. - 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.
All tenant routes are under /v1 and require:
Authorization: Bearer <PORT_SCHEDULE_API_KEY>
Content-Type: application/jsonExamples:
GET /v1/jobs— list active jobs (soft-deleted jobs are omitted).POST /v1/jobs— create a job (body includesname,cron_expression,timezone,http_method,url, optionalbody,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.
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.
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.
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.
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 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.
- Generate
UMAMI_APP_SECRET(session encryption key for Umami; at least 32 characters):
openssl rand -hex 32Copy the output into .env as UMAMI_APP_SECRET=….
- Set
UMAMI_HOST,UMAMI_APP_SECRET,UMAMI_DB_*, andUMAMI_ADMIN_*in the root.env(see.env.example). The deployment manager syncsUMAMI_ADMIN_*to Umami on startup, replacing the defaultadmin/umamicredentials on first boot. docker compose up -d umami(or bring up the full stack).- Log in at
https://{UMAMI_HOST}with the credentials fromUMAMI_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).
| 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 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.
- Generate
BUGSINK_SECRET_KEY:
openssl rand -base64 50Copy the output into .env as BUGSINK_SECRET_KEY=… (no django-insecure prefix).
-
Set
BUGSINK_HOST,BUGSINK_SECRET_KEY,BUGSINK_DB_*, andBUGSINK_ADMIN_*in the root.env(see.env.example).BUGSINK_ADMIN_*is used forCREATE_SUPERUSERon first boot only. -
docker compose up -d bugsink deployment-manager(or bring up the full stack). -
On startup, deployment-manager bootstraps a Bugsink API token automatically (
bugsink-manage create_auth_token), encrypts it, and stores it in theplatform_service_secretstable. No manual step required.Optional: set
BUGSINK_API_TOKENin.envto override the stored token (useful for recovery or pinning a known token). -
Log in at
https://{BUGSINK_HOST}withBUGSINK_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.
| 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.
Port-Au-Next exposes a REST API for programmatic control. Here are key endpoints:
POST /api/:app/deploy: Trigger a deployment for an applicationGET /api/apps: List all registered applicationsGET /api/apps/:name/deployments: List deployments for a specific applicationGET /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 logGET /api/apps/deployments/:appName/:deploymentId/logs/build: Tail docker build log
POST /api/apps/:appId/preview-branches: Enable preview branches for an appPOST /api/apps/:appId/preview-branches/:branch/deploy: Deploy a preview branchDELETE /api/apps/:appId/preview-branches/:branch: Delete a preview branchGET /api/apps/:appId/preview-branches: List active preview branches
POST /api/auth/login: Authenticate user and get sessionPOST /api/auth/logout: End current sessionPOST /api/auth/password: Change user passwordGET /api/auth/session: Get current session info
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
- 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
Port-Au-Next is released under the MIT License. See LICENSE for details.
Port-Au-Next: Deploy Next.js applications on your terms
