VibeWarden + Rails¶
This guide shows how to put VibeWarden in front of a Ruby on Rails application running in Docker Compose. VibeWarden handles TLS, authentication (Ory Kratos), rate limiting, and security headers. Rails receives only authenticated, validated requests with identity injected as HTTP headers.
Architecture¶
Internet
│
▼ :443 (HTTPS)
VibeWarden (Caddy embedded)
│ validates session cookie → Kratos
│ injects X-User-* headers
▼ :3000 (HTTP, internal, via Puma)
Rails app
Your Rails app listens on port 3000 on the internal Docker network via Puma. 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: "rails" # 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"
- "/assets/*"
- "/packs/*"
- "/favicon.ico"
- "/robots.txt"
- "/api/public/*"
- "/up" # Rails 7.1+ health check
session_cookie_name: "ory_kratos_session"
login_url: "/login"
body_size:
max: "10MB"
overrides:
- path: /uploads
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"
- "/api/v1/:resource"
- "/api/v1/:resource/:id"
security_headers:
enabled: true
hsts_max_age: 31536000
hsts_include_subdomains: true
content_type_nosniff: true
frame_option: "DENY"
# Rails uses inline scripts for UJS and Turbo — adjust CSP per your stack.
content_security_policy: "default-src 'self'"
referrer_policy: "strict-origin-when-cross-origin"
cross_origin_opener_policy: "same-origin"
cross_origin_resource_policy: "same-origin"
suppress_via_header: true
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
# App-level Postgres (separate DB for your Rails app)
app-postgres:
image: postgres:17-alpine
container_name: myapp-app-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${APP_DB_USER}
POSTGRES_PASSWORD: ${APP_DB_PASSWORD}
POSTGRES_DB: ${APP_DB_NAME}
volumes:
- app_postgres_data:/var/lib/postgresql/data
networks:
- myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${APP_DB_USER} -d ${APP_DB_NAME}"]
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: rails
VIBEWARDEN_UPSTREAM_PORT: "3000"
VIBEWARDEN_SERVER_HOST: "0.0.0.0"
VIBEWARDEN_ADMIN_TOKEN: ${VIBEWARDEN_ADMIN_TOKEN}
depends_on:
kratos:
condition: service_healthy
rails:
condition: service_healthy
networks:
- myapp
rails:
image: your-registry/your-rails-app:latest
container_name: myapp-rails
restart: unless-stopped
environment:
RAILS_ENV: production
RAILS_LOG_TO_STDOUT: "true"
DATABASE_URL: postgres://${APP_DB_USER}:${APP_DB_PASSWORD}@app-postgres:5432/${APP_DB_NAME}
SECRET_KEY_BASE: ${RAILS_SECRET_KEY_BASE}
RAILS_SERVE_STATIC_FILES: "true"
expose:
- "3000"
networks:
- myapp
depends_on:
app-postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/up"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
app_postgres_data:
caddy_data:
networks:
myapp:
driver: bridge
Rails has its own application database (
app-postgres) separate from the Kratos database (postgres). This separation keeps concerns clean.
Reading X-User-* headers in Rails¶
VibeWarden injects identity headers on every authenticated request before forwarding
to the upstream. Rack (the underlying interface for Rails) transforms HTTP header names
to uppercase with an HTTP_ prefix and hyphens replaced by underscores.
Available headers¶
| HTTP Header | Rack env key | Description |
|---|---|---|
X-User-ID |
HTTP_X_USER_ID |
Kratos identity UUID |
X-User-Email |
HTTP_X_USER_EMAIL |
Primary email address |
X-User-Verified |
HTTP_X_USER_VERIFIED |
"true" if email is verified |
X-Session-ID |
HTTP_X_SESSION_ID |
Kratos session UUID |
Middleware¶
Create a Rack middleware that reads the VibeWarden headers and makes them available
via request.env:
# app/middleware/vibewarden_identity.rb
# VibeWardenIdentity is a Rack middleware that reads the X-User-* headers
# injected by VibeWarden and makes them available as request.env keys.
#
# Only trust these headers when all requests come through VibeWarden.
# Never expose your Rails app directly to the internet.
class VibeWardenIdentity
USER_ID_HEADER = "HTTP_X_USER_ID".freeze
EMAIL_HEADER = "HTTP_X_USER_EMAIL".freeze
VERIFIED_HEADER = "HTTP_X_USER_VERIFIED".freeze
SESSION_ID_HEADER = "HTTP_X_SESSION_ID".freeze
def initialize(app)
@app = app
end
def call(env)
env[:vw_user_id] = env[USER_ID_HEADER]
env[:vw_user_email] = env[EMAIL_HEADER]
env[:vw_verified] = env[VERIFIED_HEADER] == "true"
env[:vw_session_id] = env[SESSION_ID_HEADER]
@app.call(env)
end
end
Register the middleware in config/application.rb:
# config/application.rb
require_relative "../app/middleware/vibewarden_identity"
module MyApp
class Application < Rails::Application
# Insert after the standard middleware stack so logging and
# exception handling middleware run first.
config.middleware.use VibeWardenIdentity
end
end
Concern for controllers¶
Create an ActionController concern that provides a current_user helper and a
require_vibewarden_user before action:
# app/controllers/concerns/vibewarden_authenticated.rb
module VibeWardenAuthenticated
extend ActiveSupport::Concern
included do
helper_method :current_vw_user
end
# current_vw_user returns a frozen hash with the authenticated user's
# attributes, or nil if the request is unauthenticated.
def current_vw_user
return nil unless request.env[:vw_user_id].present?
@current_vw_user ||= {
id: request.env[:vw_user_id],
email: request.env[:vw_user_email],
verified: request.env[:vw_verified],
session_id: request.env[:vw_session_id],
}.freeze
end
# require_vibewarden_user halts the request with 401 if VibeWarden did
# not inject a user identity. Use as a before_action.
def require_vibewarden_user
return if current_vw_user
render json: { error: "unauthorized" }, status: :unauthorized
end
end
Include the concern in ApplicationController:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include VibeWardenAuthenticated
# Protect all actions by default. Override in specific controllers.
before_action :require_vibewarden_user
end
Controllers¶
# app/controllers/api/me_controller.rb
module Api
class MeController < ApplicationController
def show
render json: {
id: current_vw_user[:id],
email: current_vw_user[:email],
verified: current_vw_user[:verified],
}
end
end
end
Skip authentication for public controllers:
# app/controllers/pages_controller.rb
class PagesController < ApplicationController
skip_before_action :require_vibewarden_user, only: %i[login register]
def login
# Render your login page. The form submits to /self-service/login
# which VibeWarden proxies to Kratos.
end
def register
# Render your registration page.
end
end
Views — accessing the current user¶
Because current_vw_user is declared as a helper_method, it is available in views:
<%# app/views/dashboard/index.html.erb %>
<h1>Welcome, <%= current_vw_user[:email] %></h1>
<% unless current_vw_user[:verified] %>
<div class="alert">Please verify your email address.</div>
<% end %>
Rails configuration for production behind a proxy¶
Since VibeWarden terminates TLS and forwards HTTP to Rails, configure Rails to trust the forwarded headers:
# config/environments/production.rb
Rails.application.configure do
# Trust the X-Forwarded-Proto header set by VibeWarden.
# This makes request.ssl? return true for HTTPS requests.
config.force_ssl = false # VibeWarden handles the redirect; do not double-redirect
config.assume_ssl = true # tell Rails it is behind an SSL-terminating proxy
# VibeWarden sets security headers — disable Rails' own to avoid duplicates.
config.action_dispatch.default_headers = {
# Keep only headers that VibeWarden does not set.
# Remove entries that VibeWarden already handles.
}
# Log to stdout for Docker log collection.
config.logger = ActiveSupport::Logger.new($stdout)
config.log_level = :info
end
CSRF protection¶
Rails' built-in CSRF protection applies to all state-mutating requests (POST, PUT,
PATCH, DELETE) with Content-Type: application/x-www-form-urlencoded or
multipart/form-data. VibeWarden does not bypass CSRF protection — Rails still
validates the CSRF token.
For JSON API endpoints that use VibeWarden for authentication, you may use:
class ApiController < ApplicationController
protect_from_forgery with: :null_session # or :exception for strict mode
end
Health check (Rails 7.1+)¶
Rails 7.1 adds a built-in health check at /up that returns 200 OK when the app
is ready. Register /up in auth.public_paths in vibewarden.yaml so VibeWarden
passes it through without a session check:
For older Rails versions, add a minimal health route:
# config/routes.rb
Rails.application.routes.draw do
get "/health", to: proc { [200, {}, [{ status: "ok" }.to_json]] }
# ...
end
Logging¶
Rails logs to stdout in the Docker setup. VibeWarden enriches the request with user headers, which you can add to Rails' log tags for correlation:
# config/environments/production.rb
config.log_tags = [
:request_id,
->(req) { req.env[:vw_user_id] || "anonymous" },
->(req) { req.env[:vw_session_id] || "no-session" },
]
This prepends [request_id] [user_id] [session_id] to every Rails log line,
making it easy to trace a user's requests across VibeWarden and Rails logs.