feat: swap Redis for Dragonfly, fix remote installs, self-hosted email-only auth

- Replace redis:7-alpine with Dragonfly (Redis-protocol compatible; go-redis
  client unchanged) in root, infra, and backend-dev compose files. Bounded
  defaults --proactor_threads/--maxmemory so it boots on small VPSs.
- Frontend now defaults to same-origin API calls; nginx proxies /api/* to the
  backend (WebSocket-aware, docker DNS resolver for upstream re-resolution).
  Removes the baked localhost:8082 that broke installs reached by IP/domain.
- /auth/bootstrap returns configured OAuth providers; the UI hides
  GitHub/Google buttons unless real credentials exist. Self-hosted stays
  email/password only - OAuth reserved for a future managed offering.
- Sidecar ignores PLACEHOLDER_* OAuth credentials and trusts same-host
  origins dynamically (Origin host == request Host) so remote installs pass
  origin checks without config; cross-origin requests still rejected.
- install.sh writes ENVIRONMENT=production; COOKIE_SECURE=false in
  production is now a startup warning instead of fatal (plain-HTTP installs).
- Bootstrap failure surfaces an error notice instead of silently rendering
  the sign-in page.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-21 17:24:43 +02:00
1 parent 9ba2bd06c5
commit 060038cb6f
21 files changed
+207 -83

No files matched your search

+16 -5
View File
@@ -1,4 +1,5 @@
# Local development env file. Use .env.prod for production. # Local development env file. install.sh writes ENVIRONMENT=production for
# self-hosted installs - keep development here only for the checkout workflow.
# Domain Configuration # Domain Configuration
DOMAIN=localhost DOMAIN=localhost
@@ -28,9 +29,12 @@ AUTO_MIGRATE=true
MIGRATION_LOCK_TIMEOUT=2m MIGRATION_LOCK_TIMEOUT=2m
SEED_DATA_ON_START=false SEED_DATA_ON_START=false
# Redis Configuration # Dragonfly Configuration (Redis-compatible cache - REDIS_* names kept for compatibility)
REDIS_PASSWORD=your_secure_redis_password REDIS_PASSWORD=your_secure_redis_password
REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0 REDIS_URL=redis://:your_secure_redis_password@localhost:6379/0
# DRAGONFLY_VERSION=latest
# DRAGONFLY_THREADS=1
# DRAGONFLY_MAXMEMORY=256mb
# Application Configuration # Application Configuration
# In production this must be a strong value with at least 32 characters. # In production this must be a strong value with at least 32 characters.
@@ -40,8 +44,11 @@ CONTAINR_AGENT_AUTH_TOKEN=replace_with_strong_agent_secret
# Optional rotation list (comma-separated). When set, this takes precedence. # Optional rotation list (comma-separated). When set, this takes precedence.
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret # CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
CORS_ORIGINS=http://localhost:3000,http://localhost:8080 CORS_ORIGINS=http://localhost:3000,http://localhost:8080
VITE_API_URL=http://localhost:8082 # Optional build-time API URL overrides. Leave empty for the default
VITE_AUTH_URL=http://localhost:8082/api/auth # same-origin setup - the frontend nginx proxies /api to the backend, which
# works from any host or IP without rebuilding the image.
# VITE_API_URL=https://api.example.com
# VITE_AUTH_URL=https://api.example.com/api/auth
AUTH_PORT=3001 AUTH_PORT=3001
# In production this must be true. # In production this must be true.
COOKIE_SECURE=false COOKIE_SECURE=false
@@ -54,6 +61,8 @@ BETTER_AUTH_AUTO_MIGRATE=true
BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN BETTER_AUTH_INTERNAL_TOKEN=PLACEHOLDER_INTERNAL_AUTH_TOKEN
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001 BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
# Extra trusted origins (comma-separated). Same-origin traffic is trusted
# automatically, so this only needs entries for cross-origin callers.
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082 BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8082
# Optional explicit auth DB settings (recommended when password contains URL special chars) # Optional explicit auth DB settings (recommended when password contains URL special chars)
DB_HOST=postgres DB_HOST=postgres
@@ -64,7 +73,9 @@ DB_PASSWORD=your_secure_postgres_password
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:8082 BACKEND_URL=http://localhost:8082
# OAuth (user auth) # OAuth (user auth) - GitHub/Google sign-in is reserved for the managed
# offering. Self-hosted installs use email/password only; PLACEHOLDER_*
# values are treated as unset and the buttons stay hidden.
GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID GITHUB_CLIENT_ID=PLACEHOLDER_GITHUB_OAUTH_CLIENT_ID
GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET GITHUB_CLIENT_SECRET=PLACEHOLDER_GITHUB_OAUTH_CLIENT_SECRET
GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID
+1 -1
View File
@@ -109,7 +109,7 @@ jobs:
BETTER_AUTH_SECRET=$(openssl rand -hex 32) BETTER_AUTH_SECRET=$(openssl rand -hex 32)
BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN
docker compose -f infra/docker-compose.yml up -d --build postgres redis backend frontend traefik docker compose -f infra/docker-compose.yml up -d --build postgres dragonfly backend frontend traefik
- name: Wait for backend health - name: Wait for backend health
run: | run: |
+4 -2
View File
@@ -46,7 +46,7 @@ click away.
- **Managed databases** — one-click provisioning with backup/restore - **Managed databases** — one-click provisioning with backup/restore
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry - **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
- **Templates, cron, previews** — service catalog, scheduled jobs, preview deploys - **Templates, cron, previews** — service catalog, scheduled jobs, preview deploys
- **Auth & security** — Better Auth sessions (email + OAuth), audit logs, vuln scans - **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vuln scans
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel - **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
## Quick Start ## Quick Start
@@ -60,6 +60,8 @@ curl -fsSL https://raw.githubusercontent.com/Dvorinka/Containr/main/install.sh |
Installs into `./containr`, generates secrets, pulls the published `:latest` Installs into `./containr`, generates secrets, pulls the published `:latest`
images, and starts the stack — UI at `http://localhost:3000`, API at images, and starts the stack — UI at `http://localhost:3000`, API at
`http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`. `http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`.
On first visit you create the owner account (email/password); registration
then closes automatically.
Override with env vars: Override with env vars:
@@ -80,7 +82,7 @@ see [docs/guides/](docs/guides/) and `infra/docker-compose.yml`.
``` ```
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar app/backend/ Go API (Gin) · PostgreSQL · Dragonfly · Docker SDK · Better Auth sidecar
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
docs/ Guides, OpenAPI spec, design documents docs/ Guides, OpenAPI spec, design documents
``` ```
+1 -1
View File
@@ -5,7 +5,7 @@
### Prerequisites ### Prerequisites
- Go 1.21+ - Go 1.21+
- PostgreSQL 12+ - PostgreSQL 12+
- Redis (optional) - Dragonfly (Redis-compatible cache)
### Environment Variables ### Environment Variables
+32 -6
View File
@@ -46,10 +46,15 @@ const database = new PostgresDialect({
pool, pool,
}); });
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || ''; const realCredential = (value) => {
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || ''; const trimmed = (value || '').trim();
const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || ''; return trimmed && !trimmed.startsWith('PLACEHOLDER_') ? trimmed : '';
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || ''; };
const githubClientId = realCredential(env.GITHUB_CLIENT_ID);
const githubClientSecret = realCredential(env.GITHUB_CLIENT_SECRET);
const googleClientId = realCredential(env.GOOGLE_CLIENT_ID);
const googleClientSecret = realCredential(env.GOOGLE_CLIENT_SECRET);
const socialProviders = {}; const socialProviders = {};
if (githubClientId && githubClientSecret) { if (githubClientId && githubClientSecret) {
@@ -67,7 +72,7 @@ if (googleClientId && googleClientSecret) {
}; };
} }
const trustedOrigins = [ const staticTrustedOrigins = [
...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS), ...splitCsv(env.BETTER_AUTH_TRUSTED_ORIGINS),
env.FRONTEND_URL, env.FRONTEND_URL,
env.BACKEND_URL, env.BACKEND_URL,
@@ -86,12 +91,33 @@ const trustedOrigins = [
.map((origin) => (origin || '').trim()) .map((origin) => (origin || '').trim())
.filter(Boolean); .filter(Boolean);
// Self-hosted installs are reached over arbitrary hosts/IPs, so the static
// list above cannot cover them. Trust an Origin only when it matches the
// request's own Host header - that is genuine same-origin traffic (browsers
// cannot forge either header cross-origin). Do NOT consult x-forwarded-host
// here: clients can set it freely.
const resolveTrustedOrigins = (request) => {
const origins = new Set(staticTrustedOrigins);
const host = request?.headers?.get('host');
const origin = request?.headers?.get('origin');
if (host && origin) {
try {
if (new URL(origin).host === host) {
origins.add(origin);
}
} catch {
// malformed Origin header - leave untrusted
}
}
return [...origins];
};
export const auth = betterAuth({ export const auth = betterAuth({
appName: env.BETTER_AUTH_APP_NAME || 'Containr', appName: env.BETTER_AUTH_APP_NAME || 'Containr',
baseURL: env.BETTER_AUTH_URL || defaultBackendURL, baseURL: env.BETTER_AUTH_URL || defaultBackendURL,
basePath: '/api/auth', basePath: '/api/auth',
secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN', secret: env.BETTER_AUTH_SECRET || 'PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN',
trustedOrigins, trustedOrigins: resolveTrustedOrigins,
database, database,
user: { user: {
modelName: 'auth_users', modelName: 'auth_users',
+3
View File
@@ -89,6 +89,9 @@ func main() {
// Setup Gin router // Setup Gin router
if cfg.IsProduction() { if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
if !cfg.CookieSecure {
log.Println("Warning: COOKIE_SECURE=false in production - session cookies are sent over plain HTTP. Enable TLS and set COOKIE_SECURE=true.")
}
} }
router := gin.New() router := gin.New()
+6 -6
View File
@@ -14,15 +14,15 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
redis: dragonfly:
image: redis:7-alpine image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
env_file: env_file:
- ../../.env.prod - ../../.env.prod
command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""] command: ["sh", "-c", "exec dragonfly --requirepass \"$${REDIS_PASSWORD}\" --proactor_threads=1 --maxmemory=256mb --dir=/data"]
ports: ports:
- "6379:6379" - "6379:6379"
volumes: volumes:
- backend_redis_data:/data - backend_dragonfly_data:/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"] test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
interval: 10s interval: 10s
@@ -41,7 +41,7 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: dragonfly:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
@@ -52,4 +52,4 @@ services:
volumes: volumes:
backend_postgres_data: backend_postgres_data:
backend_redis_data: backend_dragonfly_data:
+18
View File
@@ -120,13 +120,31 @@ func handleAuthBootstrap(c *gin.Context) {
mode = "register" mode = "register"
} }
providers := []string{}
for _, provider := range []string{"GITHUB", "GOOGLE"} {
if oauthProviderConfigured(provider) {
providers = append(providers, strings.ToLower(provider))
}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"has_users": count > 0, "has_users": count > 0,
"user_count": count, "user_count": count,
"mode": mode, "mode": mode,
"providers": providers,
}) })
} }
// oauthProviderConfigured reports whether an OAuth provider has real
// credentials. Placeholder values from .env.example do not count, so
// self-hosted installs default to email/password only.
func oauthProviderConfigured(provider string) bool {
id := strings.TrimSpace(os.Getenv(provider + "_CLIENT_ID"))
secret := strings.TrimSpace(os.Getenv(provider + "_CLIENT_SECRET"))
return id != "" && secret != "" &&
!strings.HasPrefix(id, "PLACEHOLDER_") && !strings.HasPrefix(secret, "PLACEHOLDER_")
}
type betterAuthLoginUser struct { type betterAuthLoginUser struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
+2 -3
View File
@@ -215,9 +215,8 @@ func (c *Config) Validate() error {
return fmt.Errorf("SEED_DATA_ON_START must be false in production") return fmt.Errorf("SEED_DATA_ON_START must be false in production")
} }
if !c.CookieSecure { // COOKIE_SECURE=false is legal for plain-HTTP self-hosted installs and is
return fmt.Errorf("COOKIE_SECURE must be true in production") // reported as a startup warning in main.go rather than a fatal error.
}
if c.BetterAuthEnabled { if c.BetterAuthEnabled {
if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 || if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 ||
+5 -4
View File
@@ -108,7 +108,7 @@ func TestValidateAllowsWildcardCorsWithoutCredentialsInProduction(t *testing.T)
} }
} }
func TestValidateRejectsInsecureCookieInProduction(t *testing.T) { func TestValidateAllowsInsecureCookieInProduction(t *testing.T) {
t.Setenv("ENVIRONMENT", "production") t.Setenv("ENVIRONMENT", "production")
cfg := Config{ cfg := Config{
JWTSecret: "this-is-a-very-strong-production-secret-123", JWTSecret: "this-is-a-very-strong-production-secret-123",
@@ -120,9 +120,10 @@ func TestValidateRejectsInsecureCookieInProduction(t *testing.T) {
CORSCredentials: true, CORSCredentials: true,
} }
err := cfg.Validate() // Plain-HTTP self-hosted installs are a supported production deployment;
if err == nil || !strings.Contains(err.Error(), "COOKIE_SECURE must be true") { // insecure cookies are allowed and surfaced as a startup warning.
t.Fatalf("expected COOKIE_SECURE validation error, got %v", err) if err := cfg.Validate(); err != nil {
t.Fatalf("expected production config with insecure cookies to validate, got %v", err)
} }
} }
+4 -3
View File
@@ -13,9 +13,10 @@ RUN npm ci
# Copy source code # Copy source code
COPY . . COPY . .
# Build-time API URL injection for Vite # Build-time API URL injection for Vite. Empty = same-origin via the nginx
ARG VITE_API_URL=http://localhost:8082 # /api/ proxy, which keeps the published image usable from any host.
ARG VITE_AUTH_URL=http://localhost:8082/api/auth ARG VITE_API_URL=
ARG VITE_AUTH_URL=
ENV VITE_API_URL=${VITE_API_URL} ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_AUTH_URL=${VITE_AUTH_URL} ENV VITE_AUTH_URL=${VITE_AUTH_URL}
+22
View File
@@ -21,6 +21,12 @@ http {
keepalive_timeout 65; keepalive_timeout 65;
types_hash_max_size 2048; types_hash_max_size 2048;
# WebSocket upgrade support
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Gzip compression # Gzip compression
gzip on; gzip on;
gzip_vary on; gzip_vary on;
@@ -51,6 +57,22 @@ http {
add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Same-origin API proxy - keeps the published image host-agnostic
location /api/ {
resolver 127.0.0.11 valid=10s;
set $backend_upstream http://backend:8080;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Handle React Router # Handle React Router
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
+38 -24
View File
@@ -114,6 +114,7 @@ export function SignInPage() {
queryKey: ['auth-bootstrap'], queryKey: ['auth-bootstrap'],
queryFn: getAuthBootstrap, queryFn: getAuthBootstrap,
}); });
const providers = bootstrapQuery.data?.providers ?? [];
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -168,10 +169,14 @@ export function SignInPage() {
return ( return (
<AuthLayout> <AuthLayout>
<AuthCard title="Sign In" subtitle="Use email/password, GitHub, or Google."> <AuthCard
title="Sign In"
subtitle={providers.length > 0 ? 'Use email/password or a connected provider.' : 'Use your email and password.'}
>
{error ? <AuthErrorNotice message={error} /> : null} {error ? <AuthErrorNotice message={error} /> : null}
{info ? <AuthInfoNotice message={info} /> : null} {info ? <AuthInfoNotice message={info} /> : null}
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null} {bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
<form className="space-y-3" onSubmit={submitEmailPassword}> <form className="space-y-3" onSubmit={submitEmailPassword}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
@@ -211,30 +216,38 @@ export function SignInPage() {
</button> </button>
</form> </form>
<div className="my-5 flex items-center gap-3"> {providers.length > 0 ? (
<div className="h-px flex-1 bg-[var(--border-subtle)]" /> <>
<span className="text-[11px] uppercase tracking-wider text-[var(--text-muted)]">or continue with</span> <div className="my-5 flex items-center gap-3">
<div className="h-px flex-1 bg-[var(--border-subtle)]" /> <div className="h-px flex-1 bg-[var(--border-subtle)]" />
</div> <span className="text-[11px] uppercase tracking-wider text-[var(--text-muted)]">or continue with</span>
<div className="h-px flex-1 bg-[var(--border-subtle)]" />
</div>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
<button {providers.includes('github') ? (
type="button" <button
onClick={() => void signInWithGitHubProvider()} type="button"
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]" onClick={() => void signInWithGitHubProvider()}
> className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
<Github size={15} /> >
GitHub <Github size={15} />
</button> GitHub
<button </button>
type="button" ) : null}
onClick={() => void signInWithGoogleProvider()} {providers.includes('google') ? (
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]" <button
> type="button"
<GoogleMark /> onClick={() => void signInWithGoogleProvider()}
Google className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
</button> >
</div> <GoogleMark />
Google
</button>
) : null}
</div>
</>
) : null}
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]"> <div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span>Need access?</span> <span>Need access?</span>
@@ -292,6 +305,7 @@ export function SignUpPage() {
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap."> <AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
{error ? <AuthErrorNotice message={error} /> : null} {error ? <AuthErrorNotice message={error} /> : null}
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null} {bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
<form className="space-y-3" onSubmit={submitSignUp}> <form className="space-y-3" onSubmit={submitSignUp}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]"> <label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
+2 -1
View File
@@ -348,7 +348,8 @@ type ProjectsResponse200 = paths['/projects']['get']['responses'][200]['content'
type JsonLike = Record<string, unknown>; type JsonLike = Record<string, unknown>;
const rawBase = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082'; const configuredBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? '').trim();
const rawBase = configuredBase || window.location.origin;
const normalizedBase = rawBase.replace(/\/$/, ''); const normalizedBase = rawBase.replace(/\/$/, '');
const API_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, ''); const API_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, '');
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`; const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
+10 -3
View File
@@ -20,6 +20,7 @@ export type AuthBootstrap = {
hasUsers: boolean; hasUsers: boolean;
userCount: number; userCount: number;
mode: 'register' | 'login'; mode: 'register' | 'login';
providers: string[];
}; };
export class AuthError extends Error { export class AuthError extends Error {
@@ -32,15 +33,16 @@ export class AuthError extends Error {
} }
} }
const rawAuthBase = (import.meta.env.VITE_AUTH_URL as string | undefined) ?? 'http://localhost:8082/api/auth'; const configuredAuthBase = ((import.meta.env.VITE_AUTH_URL as string | undefined) ?? '').trim();
const AUTH_BASE = rawAuthBase.replace(/\/$/, ''); const AUTH_BASE = (configuredAuthBase || `${window.location.origin}/api/auth`).replace(/\/$/, '');
export function getAuthBaseUrl(): string { export function getAuthBaseUrl(): string {
return AUTH_BASE; return AUTH_BASE;
} }
export async function getAuthBootstrap(): Promise<AuthBootstrap> { export async function getAuthBootstrap(): Promise<AuthBootstrap> {
const apiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082').replace(/\/$/, ''); const configuredApiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? '').trim();
const apiBase = (configuredApiBase || window.location.origin).replace(/\/$/, '');
const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`; const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`;
const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, { const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, {
method: 'GET', method: 'GET',
@@ -56,10 +58,15 @@ export async function getAuthBootstrap(): Promise<AuthBootstrap> {
throw new AuthError(message, response.status); throw new AuthError(message, response.status);
} }
const providers = Array.isArray(payload?.providers)
? payload.providers.filter((provider): provider is string => typeof provider === 'string')
: [];
return { return {
hasUsers: Boolean(payload?.has_users), hasUsers: Boolean(payload?.has_users),
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0, userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
mode: payload?.mode === 'register' ? 'register' : 'login', mode: payload?.mode === 'register' ? 'register' : 'login',
providers,
}; };
} }
+10
View File
@@ -6,6 +6,16 @@ import path from 'path'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
proxy: {
// Same-origin API path for dev - matches the nginx proxy in the shipped image
'/api': {
target: 'http://localhost:8082',
changeOrigin: true,
ws: true,
},
},
},
test: { test: {
globals: true, globals: true,
environment: 'happy-dom', environment: 'happy-dom',
+13 -9
View File
@@ -16,13 +16,17 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
redis: dragonfly:
image: redis:7-alpine image: docker.dragonflydb.io/dragonflydb/dragonfly:${DRAGONFLY_VERSION:-latest}
command: redis-server --requirepass ${REDIS_PASSWORD:-dev_redis_123} command:
- --requirepass=${REDIS_PASSWORD:-dev_redis_123}
- --proactor_threads=${DRAGONFLY_THREADS:-1}
- --maxmemory=${DRAGONFLY_MAXMEMORY:-256mb}
- --dir=/data
ports: ports:
- "${REDIS_PORT:-6379}:6379" - "${REDIS_PORT:-6379}:6379"
volumes: volumes:
- redis_data:/data - dragonfly_data:/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-dev_redis_123} ping | grep PONG"] test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-dev_redis_123} ping | grep PONG"]
interval: 10s interval: 10s
@@ -40,7 +44,7 @@ services:
- "${API_PORT:-8082}:8080" - "${API_PORT:-8082}:8080"
environment: environment:
DATABASE_URL: postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable DATABASE_URL: postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
REDIS_URL: redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD:-dev_redis_123}@dragonfly:6379/0
PORT: 8080 PORT: 8080
HOST: 0.0.0.0 HOST: 0.0.0.0
ENVIRONMENT: ${ENVIRONMENT:-development} ENVIRONMENT: ${ENVIRONMENT:-development}
@@ -83,7 +87,7 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: dragonfly:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
@@ -100,8 +104,8 @@ services:
context: ./app/frontend context: ./app/frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:8082} VITE_API_URL: ${VITE_API_URL:-}
VITE_AUTH_URL: ${VITE_AUTH_URL:-http://localhost:8082/api/auth} VITE_AUTH_URL: ${VITE_AUTH_URL:-}
ports: ports:
- "${HTTP_PORT:-3000}:80" - "${HTTP_PORT:-3000}:80"
depends_on: depends_on:
@@ -111,4 +115,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data: dragonfly_data:
+4 -4
View File
@@ -68,7 +68,7 @@ Internet → Traefik (Port 80/443)
├── Frontend (React/Nginx) ├── Frontend (React/Nginx)
├── Backend (Go API) ├── Backend (Go API)
├── PostgreSQL (Database) ├── PostgreSQL (Database)
└── Redis (Cache) └── Dragonfly (Cache)
``` ```
## Configuration Files ## Configuration Files
@@ -138,8 +138,8 @@ docker-compose up -d --force-recreate
# Backup PostgreSQL # Backup PostgreSQL
docker-compose exec postgres pg_dump -U containr_user containr > backup.sql docker-compose exec postgres pg_dump -U containr_user containr > backup.sql
# Backup Redis # Backup Dragonfly (snapshot)
docker-compose exec redis redis-cli --rdb /data/dump.rdb docker-compose exec dragonfly redis-cli -a "$REDIS_PASSWORD" save
``` ```
### SSL Certificates ### SSL Certificates
@@ -211,7 +211,7 @@ docker-compose up -d
### Performance Tuning ### Performance Tuning
1. **Nginx Caching** - Already configured in `nginx.conf` 1. **Nginx Caching** - Already configured in `nginx.conf`
2. **Redis Caching** - Configure in your application 2. **Dragonfly Caching** - Configure in your application
3. **Database Pooling** - Adjust connection limits in Go app 3. **Database Pooling** - Adjust connection limits in Go app
## Production Tips ## Production Tips
+1 -1
View File
@@ -5,7 +5,7 @@
### Prerequisites ### Prerequisites
- Go 1.21+ - Go 1.21+
- PostgreSQL 12+ - PostgreSQL 12+
- Redis (optional) - Dragonfly or Redis-compatible cache (optional)
### Environment Variables ### Environment Variables
+14 -10
View File
@@ -65,12 +65,16 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
redis: dragonfly:
image: redis:7-alpine image: docker.dragonflydb.io/dragonflydb/dragonfly:${DRAGONFLY_VERSION:-latest}
container_name: containr-redis container_name: containr-dragonfly
command: redis-server --requirepass ${REDIS_PASSWORD:-dev_redis_123} command:
- --requirepass=${REDIS_PASSWORD:-dev_redis_123}
- --proactor_threads=${DRAGONFLY_THREADS:-1}
- --maxmemory=${DRAGONFLY_MAXMEMORY:-256mb}
- --dir=/data
volumes: volumes:
- redis_data:/data - dragonfly_data:/data
networks: networks:
- containr-network - containr-network
restart: unless-stopped restart: unless-stopped
@@ -89,7 +93,7 @@ services:
- "8082:8080" - "8082:8080"
environment: environment:
- DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable - DATABASE_URL=postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable
- REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379/0 - REDIS_URL=redis://:${REDIS_PASSWORD:-dev_redis_123}@dragonfly:6379/0
- PORT=8080 - PORT=8080
- HOST=0.0.0.0 - HOST=0.0.0.0
- ENVIRONMENT=${ENVIRONMENT:-production} - ENVIRONMENT=${ENVIRONMENT:-production}
@@ -135,7 +139,7 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: dragonfly:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
@@ -160,8 +164,8 @@ services:
context: ../app/frontend context: ../app/frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:8082} VITE_API_URL: ${VITE_API_URL:-}
VITE_AUTH_URL: ${VITE_AUTH_URL:-http://localhost:8082/api/auth} VITE_AUTH_URL: ${VITE_AUTH_URL:-}
container_name: containr-frontend container_name: containr-frontend
ports: ports:
- "3000:80" - "3000:80"
@@ -203,7 +207,7 @@ services:
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local
redis_data: dragonfly_data:
driver: local driver: local
networks: networks:
+1
View File
@@ -71,6 +71,7 @@ else
} }
PG_PASS="$(gen)"; REDIS_PASS="$(gen)" PG_PASS="$(gen)"; REDIS_PASS="$(gen)"
setvar ENVIRONMENT "production"
setvar JWT_SECRET "$(gen)" setvar JWT_SECRET "$(gen)"
setvar BETTER_AUTH_SECRET "$(gen)" setvar BETTER_AUTH_SECRET "$(gen)"
setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)" setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)"