Skip to content

VibeWarden + Next.js

This guide shows how to put VibeWarden in front of a Next.js application running in Docker Compose. VibeWarden handles TLS, authentication (Ory Kratos), rate limiting, and security headers. Next.js receives only authenticated, validated requests.


Architecture

Internet
  ▼ :443 (HTTPS)
VibeWarden (Caddy embedded)
  │  validates session cookie → Kratos
  │  injects X-User-* headers
  ▼ :3000 (HTTP, internal)
Next.js app

Your Next.js app listens on port 3000 on the internal Docker network. It is never directly reachable from the internet — VibeWarden is the sole entry point.


vibewarden.yaml

server:
  host: "0.0.0.0"
  port: 443

upstream:
  host: "nextjs"   # Docker Compose service name
  port: 3000

tls:
  enabled: true
  provider: letsencrypt
  domain: "myapp.example.com"

kratos:
  public_url: "http://kratos:4433"
  admin_url:  "http://kratos:4434"

auth:
  public_paths:
    - "/login"
    - "/register"
    - "/recovery"
    - "/verification"
    - "/_next/static/*"
    - "/_next/image"
    - "/favicon.ico"
    - "/robots.txt"
    - "/api/public/*"
  session_cookie_name: "ory_kratos_session"
  login_url: "/login"

body_size:
  max: "10MB"
  overrides:
    - path: /api/upload
      max: "50MB"

rate_limit:
  enabled: true
  per_ip:
    requests_per_second: 20
    burst: 40
  per_user:
    requests_per_second: 200
    burst: 400
  trust_proxy_headers: false

log:
  level: "info"
  format: "json"

admin:
  enabled: true
  token: ""   # set via VIBEWARDEN_ADMIN_TOKEN env var

metrics:
  enabled: true
  path_patterns:
    - "/api/:resource"
    - "/api/:resource/:id"

security_headers:
  enabled: true
  hsts_max_age: 31536000
  hsts_include_subdomains: true
  content_type_nosniff: true
  frame_option: "DENY"
  # Next.js uses inline scripts for hydration — adjust CSP accordingly.
  content_security_policy: >-
    default-src 'self';
    script-src 'self' 'unsafe-inline';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self' data:
  referrer_policy: "strict-origin-when-cross-origin"
  cross_origin_opener_policy: "same-origin"
  cross_origin_resource_policy: "same-origin"
  suppress_via_header: true

Next.js requires 'unsafe-inline' for its runtime hydration scripts unless you implement per-request nonces. Tighten the CSP after testing with your specific pages.


docker-compose.yml

services:
  postgres:
    image: postgres:17-alpine
    container_name: myapp-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER:     ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB:       ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - myapp
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10

  kratos-migrate:
    image: oryd/kratos:v1.3.1
    container_name: myapp-kratos-migrate
    restart: on-failure
    environment:
      DSN: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
    volumes:
      - ./config/kratos:/etc/config/kratos:ro
    command: migrate sql -e --yes
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - myapp

  kratos:
    image: oryd/kratos:v1.3.1
    container_name: myapp-kratos
    restart: unless-stopped
    environment:
      DSN: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
      SERVE_PUBLIC_BASE_URL: https://${DOMAIN}/
      SERVE_ADMIN_BASE_URL: http://kratos:4434/
    volumes:
      - ./config/kratos:/etc/config/kratos:ro
    command: serve --config /etc/config/kratos/kratos.yml --watch-courier
    depends_on:
      kratos-migrate:
        condition: service_completed_successfully
    networks:
      - myapp
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://kratos:4434/admin/health/ready"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 10s

  vibewarden:
    image: ghcr.io/vibewarden/vibewarden:latest
    container_name: myapp-vibewarden
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./vibewarden.yaml:/vibewarden.yaml:ro
      - caddy_data:/root/.local/share/caddy
    environment:
      VIBEWARDEN_KRATOS_PUBLIC_URL: http://kratos:4433
      VIBEWARDEN_KRATOS_ADMIN_URL:  http://kratos:4434
      VIBEWARDEN_UPSTREAM_HOST:     nextjs
      VIBEWARDEN_UPSTREAM_PORT:     "3000"
      VIBEWARDEN_SERVER_HOST:       "0.0.0.0"
      VIBEWARDEN_ADMIN_TOKEN:       ${VIBEWARDEN_ADMIN_TOKEN}
    depends_on:
      kratos:
        condition: service_healthy
      nextjs:
        condition: service_healthy
    networks:
      - myapp

  nextjs:
    image: your-registry/your-nextjs-app:latest
    container_name: myapp-nextjs
    restart: unless-stopped
    environment:
      NODE_ENV: production
      PORT: "3000"
    expose:
      - "3000"
    networks:
      - myapp
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
  caddy_data:

networks:
  myapp:
    driver: bridge

Reading X-User-* headers in Next.js

VibeWarden injects identity headers on every authenticated request before forwarding to the upstream. Your Next.js app reads these headers to identify the current user without making any additional auth calls.

Available headers

Header Description
X-User-ID Kratos identity UUID
X-User-Email Primary email address from the identity traits
X-User-Verified "true" if the email address has been verified
X-Session-ID Kratos session UUID

App Router (Next.js 13+)

In a Server Component or Route Handler, read headers via the headers() API:

// app/dashboard/page.tsx
import { headers } from "next/headers";

export default async function DashboardPage() {
  const headersList = await headers();
  const userId    = headersList.get("x-user-id");
  const userEmail = headersList.get("x-user-email");
  const verified  = headersList.get("x-user-verified") === "true";

  if (!userId) {
    // VibeWarden should have redirected unauthenticated requests to /login.
    // This branch is a defensive fallback.
    return <p>Not authenticated</p>;
  }

  return (
    <main>
      <h1>Welcome, {userEmail}</h1>
      {!verified && <p>Please verify your email address.</p>}
    </main>
  );
}

In a Route Handler:

// app/api/me/route.ts
import { headers } from "next/headers";
import { NextResponse } from "next/server";

export async function GET() {
  const headersList = await headers();
  const userId    = headersList.get("x-user-id");
  const userEmail = headersList.get("x-user-email");

  if (!userId) {
    return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  }

  return NextResponse.json({ userId, userEmail });
}

Pages Router (Next.js 12 and earlier)

In getServerSideProps:

// pages/dashboard.tsx
import { GetServerSideProps } from "next";

interface Props {
  userId: string;
  userEmail: string;
}

export const getServerSideProps: GetServerSideProps<Props> = async ({ req }) => {
  const userId    = req.headers["x-user-id"] as string | undefined;
  const userEmail = req.headers["x-user-email"] as string | undefined;

  if (!userId) {
    return {
      redirect: { destination: "/login", permanent: false },
    };
  }

  return {
    props: { userId, userEmail: userEmail ?? "" },
  };
};

export default function Dashboard({ userId, userEmail }: Props) {
  return <h1>Welcome, {userEmail}</h1>;
}

In an API route:

// pages/api/me.ts
import { NextApiRequest, NextApiResponse } from "next";

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const userId    = req.headers["x-user-id"];
  const userEmail = req.headers["x-user-email"];

  if (!userId) {
    return res.status(401).json({ error: "unauthorized" });
  }

  res.json({ userId, userEmail });
}

Middleware (edge runtime)

If you use Next.js Middleware for additional routing logic, read the headers there too:

// middleware.ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const userId = request.headers.get("x-user-id");

  // VibeWarden has already enforced auth; this is an extra guard.
  if (!userId && !request.nextUrl.pathname.startsWith("/login")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Login and registration pages

The Kratos self-service UI flows are proxied by VibeWarden at /self-service/*. Your Next.js app renders the login and registration pages — they fetch the flow data from Kratos via the browser.

Minimal login page (using the Kratos browser flow API):

// app/login/page.tsx
"use client";

import { useEffect, useState } from "react";

export default function LoginPage() {
  const [flowId, setFlowId] = useState<string | null>(null);
  const [csrfToken, setCsrfToken] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const id = params.get("flow");
    if (!id) {
      // Initiate a new login flow via Kratos
      window.location.href = "/self-service/login/browser";
      return;
    }
    setFlowId(id);
    fetch(`/self-service/login/flows?id=${id}`, { credentials: "include" })
      .then((r) => r.json())
      .then((flow) => {
        const csrfNode = flow.ui?.nodes?.find(
          (n: { attributes?: { name?: string; value?: string } }) =>
            n.attributes?.name === "csrf_token"
        );
        setCsrfToken(csrfNode?.attributes?.value ?? "");
      });
  }, []);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await fetch(`/self-service/login?flow=${flowId}`, {
      method: "POST",
      credentials: "include",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        method: "password",
        csrf_token: csrfToken,
        identifier: email,
        password,
      }),
    });
    window.location.href = "/";
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
      <button type="submit">Sign in</button>
    </form>
  );
}

Refer to the Ory Kratos documentation for complete flow implementations including CSRF handling and error display.