chore: initial scaffold with running local stack and portal auth

Brings up Dezky's local development environment end-to-end:

Infrastructure (docker-compose):
- Traefik v3.7 reverse proxy with mkcert TLS (v3.2 couldn't speak Docker API 1.54)
- Postgres + Mongo + Redis with healthchecks and init script for per-service users
- Authentik 2025.10 (server + worker) as OIDC IdP
- Stalwart v0.16 mail server (image renamed from stalwartlabs/mail-server)
- OCIS 7.0 with PROXY_TLS=false and OCIS_CONFIG_DIR=/etc/ocis so init writes
  where the server reads
- Collabora office, plus the portal + provisioning service stubs
- Docker network aliases on Traefik so containers resolve auth.dezky.local etc.
  through the network (not host /etc/hosts)
- Docker socket mount parameterized for macOS Docker Desktop symlink path

Authentik provisioning (done via API after stack boot):
- ocis-provider (public client) + OCIS Files application
- dezky-portal provider (confidential) + Dezky Portal application
- Admin API token bound to akadmin manually since 2025.10's
  AUTHENTIK_BOOTSTRAP_TOKEN env var doesn't auto-materialize a token row

Portal (apps/portal):
- Nuxt 3 with nuxt-oidc-auth 1.0.0-beta.11 against generic 'oidc' preset
- Global auth middleware; login at /auth/oidc/login redirects to Authentik
- Visual implementation of Claude Design 'Auth' canvas: AuthShell, NodeMark,
  Auth* sub-components, design tokens as CSS custom properties
- Pages: auth/login, auth/expired, auth/disabled, index (post-login landing)
- mkcert root CA mounted into the portal so Node fetch trusts Authentik's
  self-signed cert (NODE_EXTRA_CA_CERTS) — dev only

Docs:
- AUTHENTIK-SETUP.md updated with manual token bind + portal provider scripted
  alternative
- NEXT-STEPS.md: Phase 1 and Phase 2 marked done with file locations and
  dev-mode caveats

Dev-mode shortcuts that need to be revisited before prod:
- skipAccessTokenParsing on the OIDC config
- NODE_EXTRA_CA_CERTS mkcert mount
- Bootstrap password still the generated value in .env
- Authentik admin token (dezky-bootstrap-token) is non-expiring
This commit is contained in:
Ronni Baslund
2026-05-23 21:25:11 +02:00
commit adfd9baafe
38 changed files with 14705 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
# ─────────────────────────────────────────────────────────────────
# Dezky Local Development — Environment Variables
# ─────────────────────────────────────────────────────────────────
#
# Copy this file to .env and fill in the values.
# Generate secure random values with: openssl rand -hex 32
#
# DO NOT commit .env to git.
# ─────────────────────────────────────────────────────────────────
# ────────────────────────────────────────
# Database root passwords
# ────────────────────────────────────────
POSTGRES_ROOT_PASSWORD=changeme_use_openssl_rand
MONGO_ROOT_PASSWORD=changeme_use_openssl_rand
REDIS_PASSWORD=changeme_use_openssl_rand
# ────────────────────────────────────────
# Per-service DB passwords
# ────────────────────────────────────────
AUTHENTIK_DB_PASSWORD=changeme_use_openssl_rand
OCIS_DB_PASSWORD=changeme_use_openssl_rand
# ────────────────────────────────────────
# Authentik
# ────────────────────────────────────────
# AUTHENTIK_SECRET_KEY must be 50+ chars
AUTHENTIK_SECRET_KEY=changeme_run_openssl_rand_hex_50
AUTHENTIK_BOOTSTRAP_PASSWORD=admin_change_this_after_first_login
# AUTHENTIK_BOOTSTRAP_TOKEN is used by the provisioning service to call Authentik API
AUTHENTIK_BOOTSTRAP_TOKEN=changeme_use_openssl_rand_hex_32
# ────────────────────────────────────────
# Stalwart Mail
# ────────────────────────────────────────
STALWART_ADMIN_PASSWORD=changeme_use_openssl_rand
# ────────────────────────────────────────
# OCIS
# ────────────────────────────────────────
OCIS_ADMIN_PASSWORD=changeme_use_openssl_rand
# ────────────────────────────────────────
# Collabora
# ────────────────────────────────────────
COLLABORA_ADMIN_PASSWORD=changeme_use_openssl_rand
+40
View File
@@ -0,0 +1,40 @@
# Environment files
.env
.env.local
.env.*.local
# TLS certificates (mkcert generated)
infrastructure/docker-compose/certs/*.pem
# Node
node_modules/
.pnpm-store/
dist/
.nuxt/
.output/
.nitro/
.cache/
# Build artifacts
*.tsbuildinfo
# Logs
*.log
logs/
# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# Docker volumes data (when bind-mounted)
data/
# Coverage
coverage/
# Temporary
tmp/
.tmp/
+236
View File
@@ -0,0 +1,236 @@
# Dezky — Local Development Environment Setup
> **For Claude Code:** This is a complete handover document for spinning up the Dezky platform locally for development. Everything you need to know is here.
## Mission for this session
The user (Ronni) has already:
- Selected brand: **Dezky** (dezky.com pending purchase)
- Run `mkcert` on macOS to generate local TLS certificates
- Decided on production target: Hetzner AX41-NVMe + Object Storage + Storage Box
- Defined the full stack (see Stack section)
Your job: Get the entire Dezky platform running locally via Docker Compose so Ronni can develop against it. The user has solid technical fluency (NestJS, Nuxt, MongoDB, Docker, Kubernetes) — skip basic explanations, but be thorough on Dezky-specific configuration.
## Stack — what we're spinning up
All components are Apache 2.0 / MIT licensed for clean commercial multi-tenant hosting.
| Service | Image | Purpose | Local URL |
|---------|-------|---------|-----------|
| Traefik | `traefik:v3.2` | Reverse proxy + TLS termination | `https://traefik.dezky.local:8443` |
| PostgreSQL | `postgres:16-alpine` | Shared RDBMS for Authentik, OCIS | (internal) |
| MongoDB | `mongo:7` | Portal application data | (internal) |
| Redis | `redis:7-alpine` | Cache + session store | (internal) |
| Authentik | `ghcr.io/goauthentik/server:2025.10` | Identity provider, OIDC/SAML | `https://auth.dezky.local` |
| Stalwart Mail | `stalwartlabs/mail-server:latest` | Mail (SMTP/IMAP/JMAP/CalDAV) | `https://mail.dezky.local` |
| OCIS | `owncloud/ocis:7.0` | File storage (S3-compatible backend) | `https://files.dezky.local` |
| Collabora | `collabora/code:latest` | Office document editor inside OCIS | `https://office.dezky.local` |
| Portal stub | (built from `./apps/portal`) | Nuxt 3 customer portal | `https://app.dezky.local` |
| Provisioning | (built from `./services/provisioning`) | NestJS provisioning worker | (internal, port 3001) |
**NOT included in this dev setup** (added in later phases):
- Jitsi Meet (4-5 sub-containers — see `docker-compose.optional.yml` when ready)
- Zulip (resource-heavy — added separately when chat features are needed)
## Critical setup invariants
1. **All code in English** — variable names, comments, schema fields, routes, function names. UI strings may be Danish for end users only.
2. **TypeScript everywhere** in apps and services.
3. **mkcert TLS certs** — the user has already run `mkcert -install` and generated wildcard certs. They will be placed in `infrastructure/docker-compose/certs/`.
4. **All hostnames use `.dezky.local`** in development. `/etc/hosts` entries required (see `scripts/setup-hosts.sh`).
5. **PostgreSQL is shared** among Authentik and OCIS. Each gets its own database and user (see `configs/postgres/init.sql`).
6. **MongoDB is dedicated** to the portal application data (matches user's existing stack pattern: Målerportal, TurtleLootLine).
7. **Secrets are .env-based** for dev — production will use SOPS/sealed-secrets in k3s.
## Expected user environment
- macOS (uses `brew` for installs)
- Docker Desktop or OrbStack running
- mkcert installed and root CA trusted
- 16+ GB RAM (stack uses ~10 GB at peak)
- `pnpm` for Node workspaces
- `git` for version control
If anything is missing, instruct the user with the exact `brew install` command.
## File structure
```
dezky/
├── CLAUDE.md # This file
├── README.md # Human-facing setup guide
├── .env.example # All required env vars
├── .gitignore
├── apps/
│ └── portal/ # Nuxt 3 portal (stub for now)
├── services/
│ └── provisioning/ # NestJS worker (stub for now)
├── packages/ # Shared TypeScript packages (empty for now)
├── infrastructure/
│ └── docker-compose/
│ ├── docker-compose.yml # Main stack
│ ├── docker-compose.optional.yml # Jitsi + Zulip (later)
│ ├── certs/ # mkcert TLS certs go here
│ │ ├── dezky.local.pem # User places mkcert output here
│ │ └── dezky.local-key.pem
│ └── configs/
│ ├── traefik/
│ │ ├── traefik.yml # Static config
│ │ └── dynamic.yml # TLS + middleware
│ ├── stalwart/
│ │ └── config.toml # Mail server config
│ ├── postgres/
│ │ └── init.sql # Create DBs/users
│ ├── authentik/
│ │ └── blueprints/ # (Optional) Pre-configured flows
│ └── ocis/
│ └── (config files mounted at runtime)
├── scripts/
│ ├── bootstrap.sh # One-command setup
│ ├── setup-hosts.sh # /etc/hosts entries
│ ├── setup-certs.sh # Copy mkcert certs to right place
│ └── reset.sh # Nuke and start fresh
└── docs/
├── SERVICES.md # Per-service reference
├── AUTHENTIK-SETUP.md # First-time Authentik walkthrough
├── TROUBLESHOOTING.md # Common issues
└── NEXT-STEPS.md # What to do after setup works
```
## Sequence for Claude Code to execute
When the user runs `claude` in the project directory, walk through:
### Phase 1: Verify environment (5 min)
```bash
# Check that required tools are installed
docker --version # Need 24.0+
docker compose version # Need v2
mkcert -version # Need 1.4+
pnpm --version # Need 9+
node --version # Need 20+
```
If anything is missing, halt and instruct user with brew install commands.
### Phase 2: Cert setup (2 min)
The user has already run `mkcert -install`. We need to:
1. Check if `infrastructure/docker-compose/certs/dezky.local.pem` exists
2. If not, run mkcert for the wildcard domain:
```bash
cd infrastructure/docker-compose/certs
mkcert "*.dezky.local" "dezky.local" "localhost" "127.0.0.1" "::1"
mv ./_wildcard.dezky.local+4.pem dezky.local.pem
mv ./_wildcard.dezky.local+4-key.pem dezky.local-key.pem
```
### Phase 3: DNS setup (2 min)
Run `scripts/setup-hosts.sh` which adds these to `/etc/hosts` (requires sudo):
```
127.0.0.1 dezky.local app.dezky.local auth.dezky.local
127.0.0.1 mail.dezky.local files.dezky.local meet.dezky.local
127.0.0.1 chat.dezky.local office.dezky.local
127.0.0.1 traefik.dezky.local
```
### Phase 4: Environment variables (2 min)
Copy `.env.example` to `.env` and generate secure random values:
```bash
cp .env.example .env
# Generate 64-char hex for each *_SECRET / *_KEY
openssl rand -hex 32 # run multiple times, paste into .env
```
### Phase 5: First boot (10 min)
```bash
cd infrastructure/docker-compose
docker compose pull # Get all images first
docker compose up -d postgres redis mongo
# Wait for healthchecks
docker compose logs -f postgres
# When ready, start identity layer
docker compose up -d authentik-server authentik-worker
# Wait for Authentik to be ready
docker compose logs -f authentik-server
# Then start everything else
docker compose up -d
```
### Phase 6: Authentik initial setup (15 min)
Open `https://auth.dezky.local` and walk through:
1. Create admin user (akadmin)
2. Configure OIDC providers for: ocis, portal, stalwart
3. Test SSO end-to-end
See `docs/AUTHENTIK-SETUP.md` for the exact steps.
### Phase 7: Verify it all works (5 min)
- `https://app.dezky.local` — Portal landing page should load
- `https://auth.dezky.local` — Authentik login screen
- `https://files.dezky.local` — OCIS should redirect to Authentik for SSO
- `https://mail.dezky.local` — Stalwart admin UI
## Important context for working on this project
### Stack rationale (DO NOT suggest replacing components)
These choices were made deliberately after extensive license/architecture research:
- **Stalwart over Mailcow**: Modern Rust, ActiveSync built-in, JMAP support, single binary
- **OCIS over Nextcloud**: Apache 2.0 vs AGPL+trademark fees for whitelabel
- **Zulip over Element/Mattermost/Rocket.Chat**: Only truly open-core-free chat option
- **Authentik over Keycloak**: Better multi-tenancy, MIT license, simpler config
- **Hetzner over AWS/GCP**: 100% EU sovereignty pitch — this is core to the business
### Code conventions
- **TypeScript strict mode** in apps/services
- **English only** in all code identifiers, comments, schema fields
- **Conventional Commits** for git messages: `feat:`, `fix:`, `chore:`, `docs:`
- **Prefer prose comments** over heavy JSDoc — explain *why*, not *what*
- **MongoDB** for portal app data (consistent with Målerportal, TurtleLootLine)
- **PostgreSQL** for services that require it (Authentik, OCIS)
### Production target (for reference, not deploy now)
Eventually moves to single Hetzner AX41-NVMe (€39/mo) with:
- Stalwart on bare-metal (not Docker)
- k3s for all other services
- Hetzner Object Storage (€5/mo) for OCIS S3 backend
- Storage Box BX11 (€3.20/mo) for Restic backups
- Storage Box BX11 in Helsinki (€3.20/mo) for DR
But locally, everything runs in Docker Compose with mkcert TLS.
## What to do if stuck
- **TLS not working**: Verify `mkcert -CAROOT` matches what Docker has access to. On macOS, the root CA must be trusted in the system keychain.
- **Authentik won't start**: PostgreSQL needs to be fully ready first. Check `docker compose logs postgres` for `database system is ready to accept connections`.
- **OCIS OIDC fails**: Authentik issuer URL must be reachable from inside the OCIS container. Use the Docker network hostname, not the public URL.
- **Stalwart port 25 conflicts**: macOS may have postfix running. Disable with `sudo launchctl unload /System/Library/LaunchDaemons/org.postfix.master.plist`.
- **Cert errors in browser**: Make sure `mkcert -install` has been run AND browser has been restarted.
See `docs/TROUBLESHOOTING.md` for detailed solutions.
## After local dev works
1. Build out the Nuxt portal (`apps/portal`) — start with auth flow via Authentik OIDC
2. Build the provisioning service (`services/provisioning`) — first endpoint: create tenant
3. Wire portal → provisioning → Authentik/OCIS/Stalwart admin APIs
4. Add Zulip + Jitsi when ready (`docker-compose.optional.yml`)
5. When portal MVP is solid → migrate to Hetzner AX41 production
---
**Last updated:** 2026-05-23
+115
View File
@@ -0,0 +1,115 @@
# Dezky
> Sovereign workspace platform for European businesses.
> Mail, files, calendar, video meetings — all EU-hosted, all open source.
## Quick start (local development)
```bash
# 1. Clone and enter
git clone <repo-url> dezky
cd dezky
# 2. Run bootstrap (handles everything)
./scripts/bootstrap.sh
# 3. Open the portal
open https://app.dezky.local
```
The bootstrap script:
- Checks prerequisites (Docker, mkcert, openssl)
- Generates wildcard TLS certificate via mkcert
- Adds /etc/hosts entries (with your permission)
- Generates secure random secrets in `.env`
- Pulls Docker images
- Starts all services in correct order
- Prints next-step instructions
## Service URLs (local development)
| Service | URL | Purpose |
|---------|-----|---------|
| Portal | https://app.dezky.local | Customer-facing landing & launcher |
| Authentik | https://auth.dezky.local | Identity provider (OIDC/SAML) |
| Files (OCIS) | https://files.dezky.local | File storage & sharing |
| Mail (Stalwart) | https://mail.dezky.local | Mail server admin UI |
| Office | https://office.dezky.local | Collabora Online editor |
| Traefik | https://traefik.dezky.local | Reverse proxy dashboard |
## What's in this repo
```
dezky/
├── apps/portal/ Nuxt 3 customer portal
├── services/provisioning/ NestJS provisioning worker
├── packages/ Shared TypeScript libraries
├── infrastructure/
│ └── docker-compose/ Local development stack
├── scripts/ Setup, reset, helpers
└── docs/ Service references & guides
```
## Prerequisites
- macOS or Linux (Windows users: use WSL2)
- Docker Desktop 24+ or OrbStack
- mkcert (`brew install mkcert`)
- pnpm 9+ (`brew install pnpm`)
- Node.js 20+
- 16 GB RAM recommended
## Common commands
```bash
# Start everything
docker compose -f infrastructure/docker-compose/docker-compose.yml up -d
# View logs
docker compose -f infrastructure/docker-compose/docker-compose.yml logs -f [service]
# Stop everything (keeps data)
docker compose -f infrastructure/docker-compose/docker-compose.yml down
# Nuke and restart (DESTROYS DATA)
./scripts/reset.sh
```
## Architecture
This is a multi-tenant SaaS platform. Each tenant gets:
- Isolated Authentik OIDC tenant
- Custom subdomain (e.g. `customer-name.dezky.local`)
- Mail domain in Stalwart with auto-generated DKIM
- Dedicated OCIS space hierarchy
- Branded launcher in the portal
All components are Apache 2.0 / MIT licensed — no per-seat fees, full whitelabel rights.
## Production
The production target is a single Hetzner AX41-NVMe server (€39/mo) with:
- Stalwart on bare-metal
- k3s for all other services
- Hetzner Object Storage (€5/mo) as OCIS S3 backend
- Storage Box BX11 (€3.20/mo) for Restic backups
- Storage Box BX11 in Helsinki (€3.20/mo) for DR
See `docs/PRODUCTION-DEPLOYMENT.md` (TBD) for migration plan.
## Stack rationale
These choices are deliberate after extensive license/architecture research. See `CLAUDE.md` for the full reasoning.
| Component | License | Why this one |
|-----------|---------|--------------|
| Stalwart Mail | Apache 2.0 | Modern Rust, ActiveSync built-in, JMAP support |
| OCIS | Apache 2.0 | Cleaner license than Nextcloud (AGPL+trademark) |
| Zulip | Apache 2.0 | Only truly open-core-free chat option |
| Authentik | MIT | Better multi-tenancy than Keycloak |
| Hetzner | N/A | 100% EU sovereignty — core to business |
## License
Application code: MIT (own code)
Third-party services: see individual service licenses in stack.
+3
View File
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
+27
View File
@@ -0,0 +1,27 @@
html,
body,
#__nuxt {
margin: 0;
padding: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
button {
font-family: inherit;
cursor: pointer;
}
a {
color: inherit;
}
+56
View File
@@ -0,0 +1,56 @@
/* Dezky design tokens — workspace surface (light/bone).
Ported from project/platform-tokens.jsx (THEMES.light + ACCENTS.signal). */
:root {
/* Surface */
--bg: #F4F3EE; /* bone */
--surface: #FAFAF7; /* paper */
--elevated: #FFFFFF;
--border: #E6E4DC; /* fog */
--border-hi: #D4D2C8;
/* Text */
--text: #0A0A0A; /* carbon */
--text-dim: rgba(10, 10, 10, 0.55);
--text-mute: rgba(10, 10, 10, 0.4);
/* Sidebar (always-dark for brand consistency) */
--side-bg: #0A0A0A;
--side-surf: #141413;
--side-text: #F4F3EE;
/* Brand accent */
--accent: #D4FF3A; /* signal */
--accent-fg: #0A0A0A;
--signal: #D4FF3A;
/* Semantic */
--ok: #1F8A5B;
--warn: #E89A1F;
--bad: #E23030;
--info: #2A6FDB;
/* Type */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-display: 'Inter Tight', 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'Menlo', monospace;
/* Field input surface */
--input-bg: var(--bg);
}
[data-theme='dark'] {
--bg: #0A0A0A;
--surface: #141413;
--elevated: #1C1C1A;
--border: #262622;
--border-hi: #33332E;
--text: #F4F3EE;
--text-dim: rgba(244, 243, 238, 0.72);
--text-mute: rgba(244, 243, 238, 0.45);
--ok: #34C77B;
--warn: #F0B14A;
--bad: #F05858;
--info: #4D8BE8;
--input-bg: rgba(244, 243, 238, 0.04);
}
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
// Brand mark — a stylized "d" inside a squircle. Ported from project/logos.jsx.
// The geometry is computed so the letterform sits ~16u inside the 100x100 viewBox.
const props = withDefaults(
defineProps<{
size?: number
fg?: string
accent?: string
bowlR?: number
stemW?: number
contR?: number
dStyle?: 'donut' | 'solid' | 'outline'
dotPos?: 'corner' | 'tittle' | 'orbit' | 'none'
dotR?: number
}>(),
{
size: 22,
fg: '#0a0a0a',
accent: '#d4ff3a',
bowlR: 14,
stemW: 7,
contR: 22,
dStyle: 'donut',
dotPos: 'corner',
dotR: 4,
},
)
const geometry = computed(() => {
const overlap = props.stemW * 0.55
const cy = 52
const cx = 50 - props.stemW / 2 + overlap / 2
const stemX = cx + props.bowlR - overlap
const stemRight = stemX + props.stemW
const capR = props.stemW / 2
const stemTop = 26
const stemBottom = cy + props.bowlR
const holeR = Math.max(2.5, props.bowlR - props.stemW - 0.5)
const bowlPath =
`M ${cx - props.bowlR} ${cy} ` +
`a ${props.bowlR} ${props.bowlR} 0 1 0 ${props.bowlR * 2} 0 ` +
`a ${props.bowlR} ${props.bowlR} 0 1 0 ${-props.bowlR * 2} 0 Z`
const counterPath =
`M ${cx - holeR} ${cy} ` +
`a ${holeR} ${holeR} 0 1 0 ${holeR * 2} 0 ` +
`a ${holeR} ${holeR} 0 1 0 ${-holeR * 2} 0 Z`
const stemPath =
`M ${stemX} ${stemTop + capR} ` +
`a ${capR} ${capR} 0 0 1 ${props.stemW} 0 ` +
`L ${stemRight} ${stemBottom} ` +
`L ${stemX} ${stemBottom} Z`
const dotByPos = {
corner: { x: 74, y: 26 },
tittle: { x: stemX + props.stemW / 2, y: stemTop - props.dotR - 3 },
orbit: { x: cx - props.bowlR - props.dotR - 2, y: cy - props.bowlR - props.dotR - 2 },
none: null,
} as const
return { bowlPath, counterPath, stemPath, dot: dotByPos[props.dotPos] }
})
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 100 100"
aria-label="Dezky"
role="img"
>
<rect x="8" y="8" width="84" height="84" :rx="contR" :fill="fg" />
<g v-if="dStyle === 'donut'" fill="#0a0a0a">
<path :d="geometry.bowlPath + ' ' + geometry.counterPath" fill-rule="evenodd" />
<path :d="geometry.stemPath" />
</g>
<g v-else-if="dStyle === 'solid'" fill="#0a0a0a">
<path :d="geometry.bowlPath" />
<path :d="geometry.stemPath" />
</g>
<circle
v-if="geometry.dot"
:cx="geometry.dot.x"
:cy="geometry.dot.y"
:r="dotR"
:fill="accent"
/>
</svg>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
// Minimal Lucide-style line icons. Add more as needed from project/platform-tokens.jsx.
const props = withDefaults(
defineProps<{
name: 'mail' | 'shield' | 'key' | 'check' | 'external' | 'arrowRight'
size?: number
stroke?: string
strokeWidth?: number
}>(),
{
size: 16,
stroke: 'currentColor',
strokeWidth: 1.6,
},
)
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
:stroke="stroke"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style="flex-shrink: 0"
>
<template v-if="name === 'mail'">
<rect x="2.5" y="5" width="19" height="14" rx="2" />
<path d="M3 7l9 6 9-6" />
</template>
<template v-else-if="name === 'shield'">
<path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" />
</template>
<template v-else-if="name === 'key'">
<circle cx="9" cy="14" r="4" />
<path d="M12.5 11L20 3.5l-2-2-2 2 2 2-2 2 2 2" />
</template>
<template v-else-if="name === 'check'">
<path d="M5 12l5 5L20 7" />
</template>
<template v-else-if="name === 'external'">
<path d="M14 4h6v6" />
<path d="M20 4l-9 9" />
<path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" />
</template>
<template v-else-if="name === 'arrowRight'">
<path d="M5 12h14" />
<path d="M13 5l7 7-7 7" />
</template>
</svg>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'primary' | 'dark' | 'ghost'
fullWidth?: boolean
type?: 'button' | 'submit'
disabled?: boolean
}>(),
{
variant: 'primary',
fullWidth: true,
type: 'button',
disabled: false,
},
)
</script>
<template>
<button
:type="type"
:disabled="disabled"
:data-variant="variant"
:data-full-width="fullWidth ? '' : null"
>
<slot name="leading" />
<slot />
</button>
</template>
<style scoped>
button {
height: 40px;
padding: 0 16px;
border-radius: 6px;
font-weight: 600;
font-size: 13px;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: filter 120ms ease, transform 60ms ease;
}
button[data-full-width] {
width: 100%;
}
button[data-variant='primary'] {
background: var(--accent);
color: var(--accent-fg);
border: 1px solid var(--accent);
}
button[data-variant='dark'] {
background: var(--text);
color: var(--bg);
border: 1px solid var(--text);
}
button[data-variant='ghost'] {
background: transparent;
color: inherit;
border: 1px solid var(--border);
}
button:hover:not(:disabled) {
filter: brightness(0.96);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts"></script>
<template>
<div class="footer-link">
<slot />
</div>
</template>
<style scoped>
.footer-link {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mute);
text-align: center;
}
.footer-link :deep(a) {
text-decoration: none;
color: inherit;
border-bottom: 1px solid currentColor;
padding-bottom: 1px;
}
.footer-link :deep(a:hover) {
color: var(--text-dim);
}
</style>
@@ -0,0 +1,49 @@
<script setup lang="ts">
defineProps<{
eyebrow?: string
title: string
body?: string
}>()
</script>
<template>
<div class="heading">
<div v-if="eyebrow" class="eyebrow">{{ eyebrow }}</div>
<h1 class="title">{{ title }}</h1>
<p v-if="body || $slots.body" class="body">
<slot name="body">{{ body }}</slot>
</p>
</div>
</template>
<style scoped>
.heading {
margin-bottom: 24px;
}
.eyebrow {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 500;
color: var(--text-dim);
margin-bottom: 10px;
}
.title {
font-family: var(--font-display);
font-weight: 600;
font-size: 24px;
letter-spacing: -0.02em;
line-height: 1.1;
margin: 0;
}
.body {
margin: 10px 0 0 0;
font-size: 13px;
color: var(--text-dim);
line-height: 1.5;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
modelValue?: string
placeholder?: string
type?: string
mono?: boolean
readonly?: boolean
}>(),
{
modelValue: '',
type: 'text',
mono: false,
readonly: false,
},
)
defineEmits<{ 'update:modelValue': [string] }>()
</script>
<template>
<label class="field">
<span class="label">{{ label }}</span>
<div class="control" :class="{ mono }">
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
:readonly="readonly"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<slot name="trailing" />
</div>
</label>
</template>
<style scoped>
.field {
display: block;
margin-bottom: 12px;
}
.label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 500;
color: var(--text-mute);
display: block;
margin-bottom: 6px;
}
.control {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 12px;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
}
.control:focus-within {
border-color: var(--text-dim);
}
.control input {
flex: 1;
background: transparent;
border: 0;
outline: 0;
font-family: inherit;
font-size: 13px;
color: inherit;
min-width: 0;
}
.control.mono input,
.control.mono {
font-family: var(--font-mono);
}
</style>
+153
View File
@@ -0,0 +1,153 @@
<script setup lang="ts">
// Reusable framing for auth screens — top bar, centered card, status footer.
// Workspace variant only (bone surface). Operator variant deferred.
withDefaults(
defineProps<{
tenant?: string
status?: string
statusTone?: 'ok' | 'warn' | 'bad'
}>(),
{
tenant: 'dezky',
status: 'All services operational',
statusTone: 'ok',
},
)
</script>
<template>
<div class="auth-shell">
<header class="bar">
<div class="brand">
<span class="brand-tile">
<NodeMark :size="22" fg="#0a0a0a" accent="#D4FF3A" />
</span>
<span class="brand-name">{{ tenant }}</span>
</div>
<span class="hostname">app.dezky.local</span>
</header>
<main class="stage">
<div class="card">
<slot />
</div>
</main>
<footer class="strip">
<div class="status">
<span class="dot" :data-tone="statusTone" />
<span>{{ status }}</span>
</div>
<div class="links">
<span>terms</span>
<span>privacy</span>
<span>status</span>
</div>
</footer>
</div>
</template>
<style scoped>
.auth-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
}
.bar {
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-tile {
width: 30px;
height: 30px;
border-radius: 7px;
background: #0a0a0a;
display: inline-flex;
align-items: center;
justify-content: center;
}
.brand-name {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
}
.hostname {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-mute);
letter-spacing: 0.04em;
}
.stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 24px;
}
.card {
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 12px;
padding: 36px 32px;
width: 100%;
max-width: 380px;
box-shadow: 0 18px 50px rgba(10, 10, 10, 0.06);
}
.strip {
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-mute);
letter-spacing: 0.04em;
}
.status {
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--ok);
}
.dot[data-tone='warn'] {
background: var(--warn);
}
.dot[data-tone='bad'] {
background: var(--bad);
}
.links {
display: flex;
gap: 16px;
}
</style>
+83
View File
@@ -0,0 +1,83 @@
// Nuxt 3 configuration for Dezky portal
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2026-01-01',
devtools: { enabled: true },
modules: ['nuxt-oidc-auth'],
css: ['~/assets/styles/tokens.css', '~/assets/styles/base.css'],
app: {
head: {
link: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap',
},
],
},
},
runtimeConfig: {
mongodbUri: process.env.MONGODB_URI,
apiBase: process.env.NUXT_API_BASE,
public: {
authUrl: process.env.NUXT_PUBLIC_AUTH_URL,
portalUrl: process.env.NUXT_PUBLIC_PORTAL_URL,
},
},
oidc: {
defaultProvider: 'oidc',
session: {
expirationCheck: true,
automaticRefresh: true,
},
middleware: {
globalMiddlewareEnabled: true,
customLoginPage: true,
},
providers: {
// Generic OIDC against our Authentik instance (provider preset key MUST be one of
// apple, auth0, cognito, entra, github, keycloak, logto, microsoft, oidc, paypal, zitadel).
oidc: {
clientId: process.env.NUXT_OIDC_CLIENT_ID || '',
clientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '',
redirectUri: process.env.NUXT_OIDC_REDIRECT_URI || '',
authorizationUrl: 'https://auth.dezky.local/application/o/authorize/',
tokenUrl: 'https://auth.dezky.local/application/o/token/',
userInfoUrl: 'https://auth.dezky.local/application/o/userinfo/',
logoutUrl: 'https://auth.dezky.local/application/o/dezky-portal/end-session/',
// Discovery URL — used by id_token validation to fetch JWKS + issuer
openIdConfiguration:
'https://auth.dezky.local/application/o/dezky-portal/.well-known/openid-configuration',
scope: ['openid', 'profile', 'email'],
userNameClaim: 'preferred_username',
responseType: 'code',
grantType: 'authorization_code',
pkce: true,
// Authentik's access tokens aren't always parseable as JWT — skip strict parsing
skipAccessTokenParsing: true,
},
},
},
vite: {
server: {
hmr: {
protocol: 'wss',
clientPort: 443,
},
},
},
nitro: {
routeRules: {
'/api/**': { cors: true },
},
},
})
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@dezky/portal",
"version": "0.0.1",
"private": true,
"description": "Dezky customer-facing portal — Nuxt 3",
"scripts": {
"dev": "nuxt dev --host 0.0.0.0 --port 3000",
"build": "nuxt build",
"preview": "nuxt preview",
"typecheck": "nuxt typecheck",
"lint": "eslint ."
},
"dependencies": {
"nuxt": "^3.13.0",
"nuxt-oidc-auth": "1.0.0-beta.11",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.5.0"
},
"packageManager": "pnpm@9.12.0"
}
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
definePageMeta({ auth: false })
const route = useRoute()
const accountEmail = computed(() => (route.query.email as string) || 'this account')
</script>
<template>
<AuthShell>
<div class="badge" data-tone="bad">
<UiIcon name="shield" :size="28" />
</div>
<AuthHeading
eyebrow="Access denied"
title="This account is suspended"
>
<template #body>
The account <b>{{ accountEmail }}</b> has been suspended by a workspace admin.
Contact them if you think this is wrong.
</template>
</AuthHeading>
<div class="admin-card">
<div class="admin-eyebrow">Workspace admin</div>
<div class="admin-name">Your administrator</div>
<div class="admin-email">contact via your organization</div>
</div>
<AuthButton variant="dark">
<template #leading>
<UiIcon name="mail" :size="14" />
</template>
Email your admin
</AuthButton>
<AuthFooterLink>
<NuxtLink to="/auth/login"> sign in with a different account</NuxtLink>
</AuthFooterLink>
</AuthShell>
</template>
<style scoped>
.badge {
width: 56px;
height: 56px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0 0 18px 0;
}
.badge[data-tone='bad'] {
background: rgba(226, 48, 48, 0.1);
color: var(--bad);
}
.admin-card {
padding: 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 18px;
}
.admin-eyebrow {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-mute);
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 6px;
}
.admin-name {
font-size: 13px;
font-weight: 500;
}
.admin-email {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-dim);
margin-top: 2px;
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
definePageMeta({ auth: false })
async function signInAgain() {
await navigateTo('/auth/oidc/login', { external: true })
}
</script>
<template>
<AuthShell
status="Some services degraded · authentik"
status-tone="warn"
>
<div class="badge" data-tone="warn">
<UiIcon name="shield" :size="28" />
</div>
<AuthHeading
eyebrow="Session ended"
title="You were signed out"
body="For your security we ended your idle session. Sign in to pick up where you left off."
/>
<AuthButton variant="primary" @click="signInAgain">Sign in again</AuthButton>
<AuthFooterLink>
not you? <span class="sep">·</span> <NuxtLink to="/auth/login">use a different account</NuxtLink>
</AuthFooterLink>
</AuthShell>
</template>
<style scoped>
.badge {
width: 56px;
height: 56px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0 0 18px 0;
}
.badge[data-tone='warn'] {
background: rgba(232, 154, 31, 0.12);
color: var(--warn);
}
.sep {
color: var(--text-dim);
padding: 0 6px;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
// Custom login page (nuxt-oidc-auth customLoginPage=true means it won't auto-bounce).
// We intercept the click and kick off the OIDC flow via the module's helper.
definePageMeta({ auth: false })
const email = ref('')
async function signIn() {
await navigateTo('/auth/oidc/login', { external: true })
}
</script>
<template>
<AuthShell>
<AuthHeading
eyebrow="Sign in"
title="Welcome back"
body="Sign in to continue to your workspace."
/>
<form @submit.prevent="signIn">
<AuthInput
v-model="email"
label="Work email"
type="email"
placeholder="you@example.com"
mono
/>
<AuthButton type="submit" variant="primary">Continue with email</AuthButton>
</form>
<div class="divider">
<span class="line" />
<span class="or">OR</span>
<span class="line" />
</div>
<AuthButton variant="dark" @click="signIn">
<template #leading>
<UiIcon name="key" :size="14" />
</template>
Single sign-on (SSO)
</AuthButton>
<AuthFooterLink>
No account? Talk to your admin.
</AuthFooterLink>
</AuthShell>
</template>
<style scoped>
.divider {
display: flex;
align-items: center;
gap: 10px;
margin: 18px 0;
}
.line {
flex: 1;
height: 1px;
background: var(--border);
}
.or {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-mute);
letter-spacing: 0.12em;
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<script setup lang="ts">
// Post-login landing. Auth middleware (nuxt-oidc-auth) gates access — anonymous
// visitors get bounced to /login by the customLoginPage config in nuxt.config.ts.
const { user, logout } = useOidcAuth()
</script>
<template>
<div class="page">
<header class="bar">
<div class="brand">
<span class="brand-tile">
<NodeMark :size="22" />
</span>
<span class="brand-name">dezky</span>
</div>
<div class="me">
<span class="email">{{ user?.userInfo?.email || user?.userName }}</span>
<button class="logout" @click="logout()">sign out</button>
</div>
</header>
<main class="stage">
<section class="hero">
<p class="eyebrow">Workspace · welcome</p>
<h1>Hi, {{ user?.userInfo?.name || user?.userName }}.</h1>
<p class="tagline">Sovereign workspace platform · all your services in one place.</p>
</section>
<section class="grid">
<a href="https://files.dezky.local" target="_blank" class="tile">
<span class="tile-name">Files</span>
<span class="tile-meta">OCIS · S3-backed storage</span>
</a>
<a href="https://mail.dezky.local" target="_blank" class="tile">
<span class="tile-name">Mail</span>
<span class="tile-meta">Stalwart · JMAP/IMAP/SMTP</span>
</a>
<a href="https://office.dezky.local" target="_blank" class="tile">
<span class="tile-name">Office</span>
<span class="tile-meta">Collabora · document editing</span>
</a>
<a href="https://auth.dezky.local" target="_blank" class="tile">
<span class="tile-name">Identity</span>
<span class="tile-meta">Authentik · SSO &amp; access</span>
</a>
</section>
</main>
</div>
</template>
<style scoped>
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.bar {
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-tile {
width: 30px;
height: 30px;
border-radius: 7px;
background: #0a0a0a;
display: inline-flex;
align-items: center;
justify-content: center;
}
.brand-name {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
}
.me {
display: flex;
align-items: center;
gap: 14px;
}
.email {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-dim);
}
.logout {
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text);
}
.logout:hover {
background: rgba(10, 10, 10, 0.04);
}
.stage {
flex: 1;
padding: 48px 24px;
max-width: 960px;
width: 100%;
margin: 0 auto;
}
.hero {
margin-bottom: 48px;
}
.eyebrow {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-mute);
margin: 0 0 12px 0;
}
.hero h1 {
margin: 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 40px;
letter-spacing: -0.025em;
line-height: 1.05;
}
.tagline {
margin: 12px 0 0 0;
color: var(--text-dim);
font-size: 15px;
line-height: 1.5;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.tile {
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: 6px;
transition: border-color 120ms ease, transform 120ms ease;
}
.tile:hover {
border-color: var(--border-hi);
transform: translateY(-1px);
}
.tile-name {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
}
.tile-meta {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mute);
}
</style>
+8122
View File
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
# Authentik First-Time Setup
After the bootstrap script completes, Authentik is running but needs to be configured. This guide walks through the initial setup.
## 1. Access Authentik
Open https://auth.dezky.local in your browser.
If you see a TLS warning, mkcert root CA isn't trusted yet. Run:
```bash
mkcert -install
```
Then restart your browser.
## 2. Initial admin setup
Authentik bootstraps with admin credentials from `.env`:
- **URL:** https://auth.dezky.local/if/flow/initial-setup/
- **Email:** admin@dezky.local
- **Password:** Value of `AUTHENTIK_BOOTSTRAP_PASSWORD` in `.env`
On first login, change the password immediately.
## 3. Configure OIDC providers
Each Dezky service that uses SSO needs an OIDC provider configured in Authentik.
### 3.1 Create OCIS provider
1. Go to **Admin Interface****Applications****Providers**
2. Click **Create**
3. Select **OAuth2/OpenID Provider**
4. Configure:
- **Name:** `ocis-provider`
- **Authorization flow:** `default-provider-authorization-implicit-consent`
- **Client type:** Public
- **Client ID:** `ocis-web`
- **Redirect URIs:**
```
https://files.dezky.local/
https://files.dezky.local/oidc-callback
```
- **Signing Key:** `authentik Self-signed Certificate`
- **Scopes:** openid, profile, email
5. Save
### 3.2 Create OCIS application
1. Go to **Applications** → **Applications**
2. Click **Create**
3. Configure:
- **Name:** `OCIS Files`
- **Slug:** `ocis`
- **Provider:** `ocis-provider` (just created)
- **Launch URL:** https://files.dezky.local
4. Save
### 3.3 Create portal provider
Same steps as OCIS, but with:
- **Provider name:** `dezky-portal`
- **Client ID:** `dezky-portal`
- **Redirect URIs:** `https://app.dezky.local/api/auth/callback`
- **Client type:** Confidential (Authentik will generate a Client Secret)
Then create the matching application:
- **Name:** `Dezky Portal` → slug auto-generates as `dezky-portal`
- **Provider:** `dezky-portal` (from above)
- **Launch URL:** `https://app.dezky.local`
The resulting issuer URL is `https://auth.dezky.local/application/o/dezky-portal/` — note the slug includes `dezky-`.
After creating, copy the generated client secret into `.env`:
```
PORTAL_OIDC_CLIENT_ID=dezky-portal
PORTAL_OIDC_CLIENT_SECRET=<paste from Authentik provider page>
PORTAL_OIDC_ISSUER=https://auth.dezky.local/application/o/dezky-portal/
```
`docker-compose.yml` passes these to the portal container as `NUXT_OIDC_*`, which `nuxt-oidc-auth` (added in Phase 2) consumes.
### Scripted alternative
If you don't want to click through the UI, this one-shot uses the API token from section 4:
```bash
TOKEN=$(grep ^AUTHENTIK_BOOTSTRAP_TOKEN .env | cut -d= -f2)
BASE=https://auth.dezky.local/api/v3
AUTH="Authorization: Bearer $TOKEN"
# Pull the same authorization flow + signing key + scope mappings that OCIS uses
FLOW=$(curl -k -s -H "$AUTH" "$BASE/flows/instances/?slug=default-provider-authorization-implicit-consent" | jq -r '.results[0].pk')
KEY=$(curl -k -s -H "$AUTH" "$BASE/providers/oauth2/?search=ocis" | jq -r '.results[0].signing_key')
MAPS=$(curl -k -s -H "$AUTH" "$BASE/providers/oauth2/?search=ocis" | jq -c '.results[0].property_mappings')
# Create provider
PK=$(curl -k -s -X POST -H "$AUTH" -H "Content-Type: application/json" "$BASE/providers/oauth2/" -d "{
\"name\": \"dezky-portal\",
\"client_id\": \"dezky-portal\",
\"client_type\": \"confidential\",
\"authorization_flow\": \"$FLOW\",
\"signing_key\": \"$KEY\",
\"redirect_uris\": [{\"matching_mode\": \"strict\", \"url\": \"https://app.dezky.local/api/auth/callback\"}],
\"property_mappings\": $MAPS,
\"sub_mode\": \"hashed_user_id\",
\"issuer_mode\": \"per_provider\"
}" | jq -r '.pk')
# Create app
curl -k -s -X POST -H "$AUTH" -H "Content-Type: application/json" "$BASE/core/applications/" -d "{
\"name\": \"Dezky Portal\",
\"slug\": \"dezky-portal\",
\"provider\": $PK,
\"meta_launch_url\": \"https://app.dezky.local\"
}" >/dev/null
# Read the generated secret and write to .env
SECRET=$(curl -k -s -H "$AUTH" "$BASE/providers/oauth2/$PK/" | jq -r '.client_secret')
cat >> .env <<EOF
PORTAL_OIDC_CLIENT_ID=dezky-portal
PORTAL_OIDC_CLIENT_SECRET=$SECRET
PORTAL_OIDC_ISSUER=https://auth.dezky.local/application/o/dezky-portal/
EOF
```
### 3.4 Create Stalwart provider (when wiring mail SSO later)
Note: Stalwart's OIDC integration is configured in `infrastructure/docker-compose/configs/stalwart/config.toml`. For local dev with internal users, OIDC is optional.
## 4. Get the API token for provisioning service
The provisioning service needs to call Authentik's API to create tenants, users, and applications. `.env` holds a pre-generated value in `AUTHENTIK_BOOTSTRAP_TOKEN`, but Authentik 2025.10 does **not** materialize that env var into a usable API token on first boot. You need to create the token once and bind it to `akadmin`.
### One-time setup
Run this from the project root — it reads the value from `.env` and inserts it as a non-expiring API token for `akadmin`:
```bash
TOKEN=$(grep ^AUTHENTIK_BOOTSTRAP_TOKEN .env | cut -d= -f2)
docker compose -f infrastructure/docker-compose/docker-compose.yml exec -T \
-e BOOTSTRAP_TOKEN="$TOKEN" authentik-server ak shell -c "
import os
from authentik.core.models import User, Token, TokenIntents
admin = User.objects.get(username='akadmin')
Token.objects.update_or_create(
identifier='dezky-bootstrap-token',
defaults={
'user': admin,
'intent': TokenIntents.INTENT_API,
'expiring': False,
'key': os.environ['BOOTSTRAP_TOKEN'],
},
)
print('Token bound to akadmin')
"
```
Alternative: create the token through the UI — **Directory → Tokens & App passwords → Create**, set `Intent: API`, `User: akadmin`, then copy the key into `.env` and restart the provisioning service.
### Verify it works
```bash
curl -k -H "Authorization: Bearer $(grep ^AUTHENTIK_BOOTSTRAP_TOKEN .env | cut -d= -f2)" \
https://auth.dezky.local/api/v3/core/users/
```
A 200 response with the user list (including `akadmin`) means the token is live. A 403 `{"detail":"Token invalid/expired"}` means the one-time setup above hasn't been run yet.
### When you need to re-run this
After `docker compose down -v` (or any reset that wipes the postgres volume), the token row is gone and you'll need to recreate it. The value in `.env` doesn't need to change — re-running the shell snippet above re-binds it.
## 5. Multi-tenancy strategy
For local dev, you can either:
**Option A: Single Authentik tenant, multiple groups**
- All Dezky test users in one Authentik instance
- Tenants modeled as Authentik groups
- Simpler for dev, less realistic
**Option B: Authentik tenants (production-mode)**
- Each Dezky customer = Authentik tenant
- Tenant subdomain pattern: `{tenant}.auth.dezky.local`
- More realistic but more setup overhead
For dev, start with Option A. The provisioning service should be built to support Option B from day one (data model includes `tenantId`).
## 6. Test SSO flow end-to-end
1. Open incognito browser to https://files.dezky.local
2. OCIS should redirect to https://auth.dezky.local for login
3. Log in with admin@dezky.local
4. Should redirect back to OCIS, logged in
If this works, OIDC integration is solid.
## Common issues
### "Issuer URL does not match"
OCIS expects exact match between `OCIS_OIDC_ISSUER` and the `iss` claim in the JWT.
Check the issuer in Authentik:
- Admin Interface → Providers → ocis-provider → Configure
- Note the **Issuer URL** at the bottom
- Update `OCIS_OIDC_ISSUER` in `docker-compose.yml` to match exactly
### "Client authentication failed"
Public clients (Client Type: Public) don't need a client secret.
Confidential clients need the secret added to the consumer's config.
For OCIS, use Public type.
For the portal (which has server-side auth), use Confidential.
### TLS verification fails between containers
Inside the Docker network, services use Docker-internal hostnames (e.g. `authentik-server`). TLS verification can fail because the cert is for `auth.dezky.local`, not `authentik-server`.
For service-to-service auth, use the issuer URL with `OCIS_INSECURE=true` only for dev. Production will use proper certs.
+163
View File
@@ -0,0 +1,163 @@
# Next Steps — After Local Stack Is Running
Once `./scripts/bootstrap.sh` completes successfully and all services are reachable, here's the development roadmap.
## Phase 1: Verify everything works (day 1) — done
- [x] `https://app.dezky.local` shows portal landing page (now the new auth design / post-login home)
- [x] `https://auth.dezky.local` shows Authentik login
- [x] Log into Authentik as admin *(still using generated `AUTHENTIK_BOOTSTRAP_PASSWORD` from `.env` — rotate before exposing to anyone else)*
- [x] Follow `docs/AUTHENTIK-SETUP.md` to configure OIDC providers (ocis + dezky-portal)
- [x] Test OCIS SSO end-to-end (login from `https://files.dezky.local`)
- [x] Verify Stalwart admin UI loads at `https://mail.dezky.local/login` *(root path 404s — admin SPA is at `/login`)*
## Phase 2: Build portal authentication (week 1) — done
Goal: Users can log in to the portal via Authentik.
- [x] Add `nuxt-oidc-auth` to `apps/portal` (`1.0.0-beta.11`)
- [x] Configure Authentik as OIDC provider (generic `oidc` preset with explicit URLs + discovery)
- [x] Implement login/logout flows (`/auth/oidc/login`, `/auth/oidc/logout` from the module)
- [x] Display logged-in user info on the portal home (`pages/index.vue` uses `useOidcAuth()`)
- [x] Add protected routes (`globalMiddlewareEnabled: true`; public pages opt out via `definePageMeta({ auth: false })`)
### Where things live
| Concern | File |
|---|---|
| OIDC module config | `apps/portal/nuxt.config.ts` (`oidc` block) |
| Custom login page | `apps/portal/pages/auth/login.vue` |
| Error states (expired / disabled) | `apps/portal/pages/auth/{expired,disabled}.vue` |
| Post-login landing | `apps/portal/pages/index.vue` |
| Visual shell + tokens | `apps/portal/components/auth/*`, `assets/styles/tokens.css` |
| Brand mark | `apps/portal/components/NodeMark.vue` |
### Dev-mode caveats (clean up before prod)
- `skipAccessTokenParsing: true` in the OIDC config — Authentik's access tokens in this setup aren't reliably JWT-parseable; production should re-evaluate
- `openIdConfiguration` is pinned to the discovery URL because the generic `oidc` preset doesn't ship a default — required for id_token JWKS validation
- `docker-compose.yml` mounts `infrastructure/docker-compose/certs/mkcert-root.pem` into the portal at `/etc/ssl/mkcert-root.pem` and sets `NODE_EXTRA_CA_CERTS` so Node fetch trusts the mkcert root CA. In prod, replace with real CA-signed certs
- Traefik has Docker network aliases for `auth.dezky.local`, `app.dezky.local`, etc. so container-to-Authentik fetch resolves inside the network without going through host `/etc/hosts`
## Phase 3: Tenant data model (week 1-2)
Goal: MongoDB schema for tenants, users, subscriptions.
- [ ] Define Mongoose schemas in `services/provisioning/src/schemas/`
- [ ] Tenant schema: id, name, slug, status, plan, billingInfo, domains
- [ ] User schema: id, tenantId, email, name, role, authentikId
- [ ] Subscription schema: tenantId, plan, status, stripeCustomerId
- [ ] Add CRUD endpoints in NestJS
Schema example:
```typescript
// services/provisioning/src/schemas/tenant.schema.ts
@Schema({ timestamps: true })
export class Tenant {
@Prop({ required: true, unique: true })
slug: string
@Prop({ required: true })
name: string
@Prop({ enum: ['pending', 'active', 'suspended', 'deleted'], default: 'pending' })
status: string
@Prop({ type: [String], default: [] })
domains: string[]
@Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' })
plan: string
@Prop()
authentikGroupId?: string
@Prop()
ocisSpaceId?: string
}
```
## Phase 4: Provisioning automation (week 2-3)
Goal: Sign up creates tenant resources across all services.
- [ ] Endpoint: `POST /tenants` — creates tenant in MongoDB
- [ ] Worker: triggers Authentik tenant/group creation via API
- [ ] Worker: configures Stalwart domain + DKIM via admin API
- [ ] Worker: creates OCIS space
- [ ] Worker: emails customer with onboarding info
Authentik API examples:
```typescript
// Create group (tenant) in Authentik
await authentikClient.coreGroupsCreate({
name: tenant.slug,
attributes: { tenantId: tenant.id, plan: tenant.plan },
})
// Create user
await authentikClient.coreUsersCreate({
username: user.email,
email: user.email,
name: user.name,
groups: [authentikGroupId],
})
```
## Phase 5: Custom webmail (week 3-4)
Goal: Branded webmail client using Stalwart's JMAP API.
- [ ] Add JMAP client library to portal
- [ ] Build inbox view in Nuxt
- [ ] Build compose dialog
- [ ] Build message view with thread support
- [ ] Style to match Dezky branding
JMAP is a modern JSON-RPC protocol — clean to work with.
## Phase 6: Production migration prep (week 4+)
When the local stack is solid and you have 2-3 pilot customers interested:
- [ ] Order Hetzner AX41-NVMe
- [ ] Order Storage Box BX11 (Falkenstein)
- [ ] Enable Hetzner Object Storage (bucket: dezky-ocis-prod)
- [ ] Build Terraform module for Hetzner provisioning
- [ ] Build Ansible playbook for bare-metal Stalwart deployment
- [ ] Set up k3s on the cloud server
- [ ] Migrate compose to Helm charts
- [ ] Configure Let's Encrypt via cert-manager
- [ ] Set up Restic backup jobs to Storage Box + B2
## Phase 7: Add Zulip and Jitsi (when chat/video needed)
These were excluded from MVP for simplicity. When ready:
- [ ] Create `infrastructure/docker-compose/docker-compose.optional.yml`
- [ ] Add Zulip stack (server + db + worker)
- [ ] Add Jitsi stack (web + prosody + jicofo + jvb)
- [ ] Configure OIDC integration with Authentik
- [ ] Add to portal launcher
## Decisions still open
These need to be made before public launch:
- [ ] Final pricing tiers (MVP, Pro, Enterprise)
- [ ] dezky.com purchase decision ($3,000 via BrandBucket)
- [ ] Final logo design (4 directions explored, need to pick one)
- [ ] Legal entity structure for the new business
- [ ] DPA (databehandleraftale) template
- [ ] Customer support process (ticket system choice)
## Long-term architecture goals
- [ ] Multi-region deployment (Hetzner Falkenstein + Helsinki)
- [ ] Disaster recovery: cross-DC Restic copies
- [ ] ISO 27001 certification via Vanta
- [ ] GDPR Article 30 record of processing activities
- [ ] SOC 2 (later, for enterprise customers)
- [ ] Customer-facing status page (Uptime Kuma or cstate)
- [ ] Public documentation site
- [ ] Self-service migration tooling from M365
+262
View File
@@ -0,0 +1,262 @@
# Services Reference
Per-service details: what each one does, where its config lives, and how to debug it.
## Traefik
**Image:** `traefik:v3.2`
**Container:** `dezky-traefik`
**URL:** https://traefik.dezky.local (dashboard)
**Purpose:** Reverse proxy, TLS termination, service discovery via Docker labels
**Config:**
- Static: `configs/traefik/traefik.yml`
- Dynamic: `configs/traefik/dynamic.yml` (TLS certs)
- Certs: `certs/dezky.local.pem` + `certs/dezky.local-key.pem`
**Debug:**
```bash
docker compose logs -f traefik
# Open https://traefik.dezky.local for dashboard
```
---
## PostgreSQL
**Image:** `postgres:16-alpine`
**Container:** `dezky-postgres`
**Internal hostname:** `postgres`
**Purpose:** Shared RDBMS for Authentik and OCIS (future)
**Databases:**
- `authentik` (owner: `authentik`)
- `ocis` (owner: `ocis`, reserved for future use)
**Debug:**
```bash
# Shell access
docker compose exec postgres psql -U postgres
# Check users
\du
# Check databases
\l
# Connect to specific DB
\c authentik
```
---
## MongoDB
**Image:** `mongo:7`
**Container:** `dezky-mongo`
**Internal hostname:** `mongo`
**Purpose:** Portal application data
**Connection:**
```
mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin
```
**Debug:**
```bash
docker compose exec mongo mongosh -u root -p $(grep MONGO_ROOT_PASSWORD .env | cut -d= -f2)
```
---
## Redis
**Image:** `redis:7-alpine`
**Container:** `dezky-redis`
**Internal hostname:** `redis`
**Purpose:** Cache and session store (used by Authentik)
**Debug:**
```bash
docker compose exec redis redis-cli -a $(grep REDIS_PASSWORD .env | cut -d= -f2)
> KEYS *
> INFO
```
---
## Authentik
**Image:** `ghcr.io/goauthentik/server:2025.10`
**Containers:** `dezky-authentik` (server) + `dezky-authentik-worker`
**URL:** https://auth.dezky.local
**Purpose:** Identity provider, SSO, MFA
**First-time setup:**
- URL: https://auth.dezky.local/if/flow/initial-setup/
- Email: `admin@dezky.local`
- Password: `AUTHENTIK_BOOTSTRAP_PASSWORD` from `.env`
**API:**
- Base: https://auth.dezky.local/api/v3
- Auth: `Authorization: Bearer <AUTHENTIK_BOOTSTRAP_TOKEN>`
- Docs: https://auth.dezky.local/api/v3/
**Debug:**
```bash
docker compose logs -f authentik-server authentik-worker
# Check API health
curl https://auth.dezky.local/-/health/ready/
```
See `docs/AUTHENTIK-SETUP.md` for OIDC configuration steps.
---
## Stalwart Mail
**Image:** `stalwartlabs/mail-server:latest`
**Container:** `dezky-stalwart`
**URL:** https://mail.dezky.local
**Purpose:** Mail server (SMTP/IMAP/JMAP/CalDAV/CardDAV/ActiveSync)
**Ports exposed:**
- 25 (SMTP)
- 465 (SMTPS)
- 587 (Submission)
- 143 (IMAP)
- 993 (IMAPS)
- 4190 (ManageSieve)
**Config:** `configs/stalwart/config.toml`
**Data:** Docker volume `dezky_stalwart_data`
**Admin login:**
- User: `admin`
- Password: `STALWART_ADMIN_PASSWORD` from `.env`
**Debug:**
```bash
docker compose logs -f stalwart
# Test SMTP
swaks --to test@dezky.local --from sender@example.com --server mail.dezky.local:25
# Check ports
docker compose port stalwart 25
```
---
## OCIS
**Image:** `owncloud/ocis:7.0`
**Container:** `dezky-ocis`
**URL:** https://files.dezky.local
**Purpose:** File storage, sharing, sync
**OIDC config:**
- Issuer: `https://auth.dezky.local/application/o/ocis/`
- Client ID: `ocis-web` (configured in Authentik)
- Auto-provision: enabled (creates OCIS user on first SSO login)
**Admin login:**
- User: `admin`
- Password: `OCIS_ADMIN_PASSWORD` from `.env`
**Storage backend:**
- Dev: local filesystem inside volume `dezky_ocis_data`
- Prod: will switch to S3 (Hetzner Object Storage)
**Debug:**
```bash
docker compose logs -f ocis
# Health check
curl -k https://files.dezky.local/
```
---
## Collabora
**Image:** `collabora/code:latest`
**Container:** `dezky-collabora`
**URL:** https://office.dezky.local
**Purpose:** Office document editing inside OCIS
**Integration with OCIS:**
- OCIS must be configured to use Collabora as its office editor
- See: OCIS app config → "wopiserver"
**Debug:**
```bash
docker compose logs -f collabora
# Discovery endpoint (used by OCIS)
curl -k https://office.dezky.local/hosting/discovery
```
---
## Portal (Nuxt 3)
**Container:** `dezky-portal`
**URL:** https://app.dezky.local
**Source:** `apps/portal/`
**Purpose:** Customer-facing portal, launcher, custom webmail
**Stack:**
- Nuxt 3
- Vue 3 + TypeScript
- Vite dev server
- pnpm for dependencies
**Hot reload:**
- File changes in `apps/portal/` trigger HMR automatically
- Vite watches via polling (configured in `nuxt.config.ts`)
**Environment:**
- `NUXT_PUBLIC_AUTH_URL`: Authentik URL (client-side)
- `NUXT_API_BASE`: provisioning service URL (server-side)
- `MONGODB_URI`: MongoDB connection string
**Debug:**
```bash
docker compose logs -f portal
# Shell into container
docker compose exec portal sh
> pnpm dev
```
---
## Provisioning Service (NestJS)
**Container:** `dezky-provisioning`
**Port:** 3001 (internal only)
**Source:** `services/provisioning/`
**Purpose:** Tenant lifecycle, billing webhooks, service orchestration
**Endpoints to implement:**
- `POST /tenants` — Create tenant
- `GET /tenants/:id` — Get tenant
- `PATCH /tenants/:id` — Update tenant
- `POST /tenants/:id/users` — Add user to tenant
- `POST /webhooks/stripe` — Billing events
**Environment:**
- `MONGODB_URI`: Portal data store
- `AUTHENTIK_API_URL` + `AUTHENTIK_API_TOKEN`
- `STALWART_API_URL` + `STALWART_ADMIN_USER/PASSWORD`
- `OCIS_API_URL`
**Debug:**
```bash
docker compose logs -f provisioning
# Test health endpoint
docker compose exec provisioning wget -qO- http://localhost:3001/health
```
+305
View File
@@ -0,0 +1,305 @@
# Troubleshooting
Common issues and fixes when running the Dezky local development stack.
## TLS / Certificate issues
### Browser shows "Not Secure" or certificate warning
mkcert root CA isn't trusted in your browser yet.
```bash
mkcert -install
```
Then **fully restart your browser** (quit, not just close window).
### Certificate not loading in Traefik
Verify the cert files exist:
```bash
ls -la infrastructure/docker-compose/certs/
# Should show:
# dezky.local.pem
# dezky.local-key.pem
```
If they're named differently (e.g. `_wildcard.dezky.local+1.pem`), rename them:
```bash
cd infrastructure/docker-compose/certs/
mv _wildcard.dezky.local+*.pem dezky.local.pem
mv _wildcard.dezky.local+*-key.pem dezky.local-key.pem
```
Then restart Traefik:
```bash
docker compose restart traefik
```
### Service-to-service TLS errors
Inside Docker, services talk via internal hostnames (e.g. `authentik-server:9000`), not `auth.dezky.local`. Internal traffic uses HTTP, not HTTPS. Only Traefik handles TLS termination.
If a service config has `https://authentik-server`, change it to `http://authentik-server`.
---
## Container startup issues
### Authentik fails to start
Most common cause: PostgreSQL not ready yet, or password mismatch.
```bash
# Check postgres is healthy
docker compose ps postgres
# Should show "healthy" in STATUS
# Check Authentik DB user exists
docker compose exec postgres psql -U postgres -c "\du"
# Should list "authentik" as a user
# Check Authentik logs
docker compose logs authentik-server | tail -50
```
If password is wrong, reset and re-bootstrap:
```bash
./scripts/reset.sh
./scripts/bootstrap.sh
```
### Port 25 conflict (Stalwart fails to bind)
macOS often has Postfix running by default:
```bash
sudo launchctl unload /System/Library/LaunchDaemons/org.postfix.master.plist 2>/dev/null || true
sudo launchctl stop org.postfix.master 2>/dev/null || true
```
Or just disable the SMTP port mapping in `docker-compose.yml` for local dev:
```yaml
stalwart:
# ports:
# - "25:25" # comment out if conflicting
```
### Port 80/443 conflict (Traefik fails to bind)
Another service is using those ports.
```bash
# Find what's using port 80
sudo lsof -nP -i:80 -sTCP:LISTEN
```
Common culprits: nginx, apache, Caddy, other Docker stacks. Stop them or change Traefik to use 8080/8443.
### OCIS crashes on first start
OCIS needs to initialize before running. The compose file does this via:
```yaml
command: ["-c", "ocis init --insecure true || true && ocis server"]
```
If init fails:
```bash
# Manually init
docker compose run --rm ocis ocis init --insecure true
# Then start
docker compose up -d ocis
```
---
## DNS / hostname issues
### `app.dezky.local` doesn't resolve
Check /etc/hosts:
```bash
grep dezky.local /etc/hosts
```
Should see entries pointing 127.0.0.1 to all hostnames. If missing, run:
```bash
./scripts/bootstrap.sh # Will offer to add them
```
Or manually:
```bash
echo "127.0.0.1 dezky.local app.dezky.local auth.dezky.local mail.dezky.local files.dezky.local office.dezky.local meet.dezky.local chat.dezky.local traefik.dezky.local" | sudo tee -a /etc/hosts
```
### Browser DNS cache holding old entry
Clear browser cache, or test from terminal:
```bash
ping app.dezky.local
# Should return 127.0.0.1
```
If terminal resolves but browser doesn't:
- Chrome: chrome://net-internals/#dns → Clear host cache
- Firefox: about:networking#dns → Clear DNS cache
---
## Authentik OIDC integration issues
### "Invalid issuer URL"
The `iss` claim in the JWT must match exactly what the consuming service expects.
```yaml
# In docker-compose.yml for OCIS:
OCIS_OIDC_ISSUER: https://auth.dezky.local/application/o/ocis/
```
The trailing slash matters. Authentik issues with trailing slash by default.
Verify the actual issuer:
```bash
curl -s https://auth.dezky.local/application/o/ocis/.well-known/openid-configuration | jq .issuer
```
### "redirect_uri not allowed"
The OAuth provider in Authentik must list every redirect URI the client might use.
For OCIS:
```
https://files.dezky.local/
https://files.dezky.local/oidc-callback
```
Add both. Patterns matter — exact match.
### Login loop (redirects forever)
Usually caused by:
1. **Time mismatch** between container and host. Check `docker compose exec ocis date` matches host clock.
2. **Cookie domain mismatch.** Cookies set for `.dezky.local` should work across subdomains.
---
## Hot reload not working
### Nuxt portal doesn't rebuild on file changes
The volume mount works on macOS but file watching needs explicit polling:
Add to `apps/portal/nuxt.config.ts`:
```ts
export default defineNuxtConfig({
vite: {
server: {
watch: {
usePolling: true,
interval: 1000,
},
},
},
})
```
### NestJS provisioning doesn't restart
Same issue. The `start:dev` command uses nodemon under the hood. Make sure your `package.json` has:
```json
{
"scripts": {
"start:dev": "nest start --watch"
}
}
```
---
## Data and reset issues
### Want to keep data but restart services
```bash
docker compose restart [service-name]
```
### Want to reset just one service
```bash
docker compose stop authentik-server authentik-worker
docker volume rm dezky_authentik_media dezky_authentik_certs
docker compose up -d authentik-server authentik-worker
```
### Full reset (nuclear option)
```bash
./scripts/reset.sh
./scripts/bootstrap.sh
```
---
## Performance issues
### Stack is using too much RAM
Check usage:
```bash
docker stats --no-stream
```
Top RAM consumers are usually:
- Zulip (4-6 GB) — disabled in main compose
- Jitsi (2-4 GB) — disabled in main compose
- Authentik server + worker (~1 GB each)
- OCIS (~1 GB)
- Collabora (1-2 GB if active document open)
For low-memory machines, disable services you're not using:
```bash
docker compose stop collabora # Save ~1 GB
docker compose stop ocis # Save ~1 GB if not testing files
```
### macOS Docker is slow
OrbStack is significantly faster than Docker Desktop on macOS:
```bash
brew install --cask orbstack
```
Or in Docker Desktop, enable VirtioFS for bind mount performance.
---
## Logs and debugging
### See logs from one service
```bash
docker compose logs -f authentik-server
```
### See logs from multiple services
```bash
docker compose logs -f authentik-server authentik-worker postgres
```
### Inspect a container
```bash
docker compose exec authentik-server sh
# or
docker compose exec postgres psql -U postgres
```
### See what's running
```bash
docker compose ps
```
### Network debugging — can services reach each other?
```bash
docker compose exec ocis ping -c 3 authentik-server
docker compose exec ocis curl -v http://authentik-server:9000/-/health/ready/
```
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# Dezky PostgreSQL initialization
# Creates databases and users for Authentik and OCIS.
# Passwords come from env vars set in docker-compose.yml.
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Authentik
CREATE USER authentik WITH PASSWORD '${AUTHENTIK_DB_PASSWORD}';
CREATE DATABASE authentik WITH OWNER authentik ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'C' TEMPLATE template0;
GRANT ALL PRIVILEGES ON DATABASE authentik TO authentik;
-- OCIS (reserved for future use; OCIS uses internal storage in dev)
CREATE USER ocis WITH PASSWORD '${OCIS_DB_PASSWORD}';
CREATE DATABASE ocis WITH OWNER ocis ENCODING 'UTF8' TEMPLATE template0;
GRANT ALL PRIVILEGES ON DATABASE ocis TO ocis;
EOSQL
# Grant schema permissions inside each newly created DB
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname authentik <<-EOSQL
GRANT ALL ON SCHEMA public TO authentik;
EOSQL
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname ocis <<-EOSQL
GRANT ALL ON SCHEMA public TO ocis;
EOSQL
echo "Dezky PostgreSQL initialization complete."
@@ -0,0 +1,91 @@
# Stalwart Mail Server — Local Development Configuration
#
# This is a minimal config for local dev. Production config will have:
# - Real TLS certs (Let's Encrypt)
# - DKIM signing with real keys
# - SPF/DMARC enforcement
# - Rspamd integration
# - Hetzner Object Storage for blob storage
#
# Reference: https://stalw.art/docs
[server]
hostname = "mail.dezky.local"
[server.listener]
"smtp" = { bind = "[::]:25", protocol = "smtp" }
"submission" = { bind = "[::]:587", protocol = "smtp", tls.implicit = false }
"submissions" = { bind = "[::]:465", protocol = "smtp", tls.implicit = true }
"imap" = { bind = "[::]:143", protocol = "imap", tls.implicit = false }
"imaps" = { bind = "[::]:993", protocol = "imap", tls.implicit = true }
"sieve" = { bind = "[::]:4190", protocol = "managesieve" }
"http" = { bind = "[::]:8080", protocol = "http" }
# ─────────────────────────────────────────────────────────────────
# Storage — RocksDB embedded for local dev (single-binary simplicity)
# Production will use PostgreSQL backend
# ─────────────────────────────────────────────────────────────────
[store."rocksdb"]
type = "rocksdb"
path = "/opt/stalwart/data"
compression = "lz4"
[storage]
data = "rocksdb"
fts = "rocksdb"
blob = "rocksdb"
lookup = "rocksdb"
directory = "internal"
# ─────────────────────────────────────────────────────────────────
# Directory — internal user store for local dev
# Production will wire OIDC to Authentik
# ─────────────────────────────────────────────────────────────────
[directory."internal"]
type = "internal"
store = "rocksdb"
# ─────────────────────────────────────────────────────────────────
# TLS — Self-signed in dev, Traefik terminates the public-facing HTTPS
# ─────────────────────────────────────────────────────────────────
[certificate."default"]
cert = "%{file:/opt/stalwart/etc/tls/cert.pem}%"
private-key = "%{file:/opt/stalwart/etc/tls/key.pem}%"
default = true
# ─────────────────────────────────────────────────────────────────
# Authentication for local development
# ─────────────────────────────────────────────────────────────────
[authentication]
fallback-admin.user = "admin"
fallback-admin.secret = "$env{STALWART_ADMIN_PASSWORD}"
# ─────────────────────────────────────────────────────────────────
# Resolver — use the Docker DNS for local dev
# ─────────────────────────────────────────────────────────────────
[resolver]
type = "system"
preserve-intermediates = true
concurrency = 2
# ─────────────────────────────────────────────────────────────────
# Logging
# ─────────────────────────────────────────────────────────────────
[tracer."stdout"]
type = "stdout"
level = "info"
ansi = false
enable = true
# ─────────────────────────────────────────────────────────────────
# Spam filtering — disabled in dev (no Rspamd configured)
# Production: integrate Rspamd via milter
# ─────────────────────────────────────────────────────────────────
[spam-filter]
enable = false
# ─────────────────────────────────────────────────────────────────
# Local development hint:
# After first boot, create your first mailbox by visiting
# https://mail.dezky.local and using admin credentials.
# ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,51 @@
# Traefik dynamic configuration — TLS certificates and middleware
#
# Uses the wildcard mkcert certificate for all *.dezky.local hostnames.
# This file is watched and reloaded automatically by Traefik.
tls:
certificates:
- certFile: /certs/dezky.local.pem
keyFile: /certs/dezky.local-key.pem
stores:
- default
stores:
default:
defaultCertificate:
certFile: /certs/dezky.local.pem
keyFile: /certs/dezky.local-key.pem
http:
middlewares:
# Strong security headers for all services
secure-headers:
headers:
frameDeny: false # OCIS/Collabora need iframes
sslRedirect: true
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 15552000
customFrameOptionsValue: "SAMEORIGIN"
# CORS for API calls between portal and provisioning service
cors:
headers:
accessControlAllowMethods:
- "GET"
- "POST"
- "PUT"
- "PATCH"
- "DELETE"
- "OPTIONS"
accessControlAllowOriginListRegex:
- "^https://([a-z0-9-]+\\.)?dezky\\.local$"
accessControlAllowHeaders:
- "Content-Type"
- "Authorization"
- "X-Requested-With"
accessControlMaxAge: 86400
addVaryHeader: true
@@ -0,0 +1,43 @@
# Traefik static configuration for Dezky local development
#
# Provides TLS termination using mkcert-generated wildcard certificate.
# Auto-discovers services via Docker labels.
global:
checkNewVersion: false
sendAnonymousUsage: false
api:
dashboard: true
insecure: true # OK for local dev only — exposes dashboard on :8080
log:
level: INFO
accessLog: {}
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ":443"
http:
tls: {}
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: dezky
watch: true
file:
filename: /etc/traefik/dynamic.yml
watch: true
@@ -0,0 +1,341 @@
# Dezky — Local Development Stack
#
# Start: docker compose up -d
# Logs: docker compose logs -f [service]
# Stop: docker compose down
# Reset: docker compose down -v (WARNING: deletes all data)
#
# Prerequisites:
# 1. mkcert root CA installed (mkcert -install)
# 2. Wildcard cert generated in ./certs/dezky.local.pem
# 3. /etc/hosts entries added (run scripts/setup-hosts.sh)
# 4. .env file created from .env.example
name: dezky
networks:
dezky:
name: dezky
driver: bridge
volumes:
postgres_data:
mongo_data:
redis_data:
authentik_media:
authentik_certs:
authentik_templates:
stalwart_data:
ocis_config:
ocis_data:
portal_node_modules:
provisioning_node_modules:
services:
# ─────────────────────────────────────────────────────────────────
# Traefik — Reverse proxy with TLS termination via mkcert
# ─────────────────────────────────────────────────────────────────
traefik:
image: traefik:v3.7
container_name: dezky-traefik
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "8443:8080" # Dashboard
volumes:
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
- ./configs/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./configs/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
- ./certs:/certs:ro
networks:
dezky:
aliases:
- traefik.dezky.local
- auth.dezky.local
- app.dezky.local
- files.dezky.local
- mail.dezky.local
- office.dezky.local
labels:
- traefik.enable=true
- traefik.http.routers.dashboard.rule=Host(`traefik.dezky.local`)
- traefik.http.routers.dashboard.service=api@internal
- traefik.http.routers.dashboard.tls=true
# ─────────────────────────────────────────────────────────────────
# PostgreSQL — Shared RDBMS (Authentik, OCIS)
# ─────────────────────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
container_name: dezky-postgres
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD}
POSTGRES_DB: postgres
AUTHENTIK_DB_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
OCIS_DB_PASSWORD: ${OCIS_DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./configs/postgres/init.sh:/docker-entrypoint-initdb.d/init.sh:ro
networks: [dezky]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# ─────────────────────────────────────────────────────────────────
# MongoDB — Portal application data
# ─────────────────────────────────────────────────────────────────
mongo:
image: mongo:7
container_name: dezky-mongo
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
MONGO_INITDB_DATABASE: dezky
volumes:
- mongo_data:/data/db
networks: [dezky]
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
# ─────────────────────────────────────────────────────────────────
# Redis — Cache + session store
# ─────────────────────────────────────────────────────────────────
redis:
image: redis:7-alpine
container_name: dezky-redis
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks: [dezky]
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 5
# ─────────────────────────────────────────────────────────────────
# Authentik — Identity provider (OIDC/SAML SSO)
# ─────────────────────────────────────────────────────────────────
authentik-server:
image: ghcr.io/goauthentik/server:2025.10
container_name: dezky-authentik
restart: unless-stopped
command: server
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_REDIS__PASSWORD: ${REDIS_PASSWORD}
AUTHENTIK_POSTGRESQL__HOST: postgres
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
AUTHENTIK_BOOTSTRAP_EMAIL: admin@dezky.local
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
volumes:
- authentik_media:/media
- authentik_certs:/certs
- authentik_templates:/templates
- ./configs/authentik/blueprints:/blueprints/custom:ro
networks: [dezky]
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.authentik.rule=Host(`auth.dezky.local`)
- traefik.http.routers.authentik.tls=true
- traefik.http.services.authentik.loadbalancer.server.port=9000
authentik-worker:
image: ghcr.io/goauthentik/server:2025.10
container_name: dezky-authentik-worker
restart: unless-stopped
command: worker
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_REDIS__PASSWORD: ${REDIS_PASSWORD}
AUTHENTIK_POSTGRESQL__HOST: postgres
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
volumes:
- authentik_media:/media
- authentik_certs:/certs
- authentik_templates:/templates
networks: [dezky]
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# ─────────────────────────────────────────────────────────────────
# Stalwart Mail — Mail server (SMTP/IMAP/JMAP/CalDAV/CardDAV/ActiveSync)
# ─────────────────────────────────────────────────────────────────
stalwart:
image: stalwartlabs/stalwart:v0.16
container_name: dezky-stalwart
restart: unless-stopped
ports:
- "25:25" # SMTP
- "465:465" # SMTPS
- "587:587" # Submission
- "143:143" # IMAP
- "993:993" # IMAPS
- "4190:4190" # ManageSieve
environment:
STALWART_FQDN: mail.dezky.local
volumes:
- stalwart_data:/opt/stalwart
- ./configs/stalwart/config.toml:/opt/stalwart/etc/config.toml:ro
networks: [dezky]
labels:
- traefik.enable=true
- traefik.http.routers.stalwart.rule=Host(`mail.dezky.local`)
- traefik.http.routers.stalwart.tls=true
- traefik.http.services.stalwart.loadbalancer.server.port=8080
# ─────────────────────────────────────────────────────────────────
# OCIS — File storage with S3-compatible backend
# ─────────────────────────────────────────────────────────────────
ocis:
image: owncloud/ocis:7.0
container_name: dezky-ocis
restart: unless-stopped
entrypoint: /bin/sh
command: ["-c", "ocis init --insecure true || true && ocis server"]
environment:
OCIS_URL: https://files.dezky.local
OCIS_LOG_LEVEL: warn
OCIS_INSECURE: "true" # dev only — self-signed certs
PROXY_HTTP_ADDR: 0.0.0.0:9200
PROXY_TLS: "false" # Traefik terminates TLS; OCIS speaks plain HTTP internally
OCIS_OIDC_ISSUER: https://auth.dezky.local/application/o/ocis/
WEB_OIDC_CLIENT_ID: ocis-web
PROXY_AUTOPROVISION_ACCOUNTS: "true"
PROXY_USER_OIDC_CLAIM: preferred_username
PROXY_USER_CS3_CLAIM: username
OCIS_ADMIN_USER_ID: ""
IDM_CREATE_DEMO_USERS: "false"
IDM_ADMIN_PASSWORD: ${OCIS_ADMIN_PASSWORD}
STORAGE_USERS_DRIVER: ocis # Local filesystem in dev
STORAGE_SYSTEM_DRIVER: ocis
OCIS_CONFIG_DIR: /etc/ocis
OCIS_BASE_DATA_PATH: /var/lib/ocis
volumes:
- ocis_config:/etc/ocis
- ocis_data:/var/lib/ocis
networks: [dezky]
depends_on:
- authentik-server
labels:
- traefik.enable=true
- traefik.http.routers.ocis.rule=Host(`files.dezky.local`)
- traefik.http.routers.ocis.tls=true
- traefik.http.services.ocis.loadbalancer.server.port=9200
# ─────────────────────────────────────────────────────────────────
# Collabora — Office document editor (integrated into OCIS)
# ─────────────────────────────────────────────────────────────────
collabora:
image: collabora/code:latest
container_name: dezky-collabora
restart: unless-stopped
environment:
aliasgroup1: https://files\\.dezky\\.local:443
DONT_GEN_SSL_CERT: "true"
extra_params: --o:ssl.enable=false --o:ssl.termination=true --o:welcome.enable=false
username: admin
password: ${COLLABORA_ADMIN_PASSWORD}
networks: [dezky]
labels:
- traefik.enable=true
- traefik.http.routers.collabora.rule=Host(`office.dezky.local`)
- traefik.http.routers.collabora.tls=true
- traefik.http.services.collabora.loadbalancer.server.port=9980
# ─────────────────────────────────────────────────────────────────
# Portal — Nuxt 3 customer portal (development mode with HMR)
# ─────────────────────────────────────────────────────────────────
portal:
image: node:20-alpine
container_name: dezky-portal
restart: unless-stopped
working_dir: /app
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
environment:
NODE_ENV: development
NUXT_HOST: 0.0.0.0
NUXT_PORT: 3000
NUXT_PUBLIC_AUTH_URL: https://auth.dezky.local
NUXT_PUBLIC_PORTAL_URL: https://app.dezky.local
NUXT_API_BASE: http://provisioning:3001
MONGODB_URI: mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin
# OIDC (confidential client) — used by Nuxt server middleware
NUXT_OIDC_CLIENT_ID: ${PORTAL_OIDC_CLIENT_ID}
NUXT_OIDC_CLIENT_SECRET: ${PORTAL_OIDC_CLIENT_SECRET}
NUXT_OIDC_ISSUER: ${PORTAL_OIDC_ISSUER}
NUXT_OIDC_REDIRECT_URI: https://app.dezky.local/auth/oidc/callback
# Session encryption (required by nuxt-oidc-auth)
NUXT_OIDC_TOKEN_KEY: ${NUXT_OIDC_TOKEN_KEY}
NUXT_OIDC_SESSION_SECRET: ${NUXT_OIDC_SESSION_SECRET}
NUXT_OIDC_AUTH_SESSION_SECRET: ${NUXT_OIDC_AUTH_SESSION_SECRET}
# Trust mkcert root CA for Node fetch (dev only)
NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem
volumes:
- ../../apps/portal:/app
- portal_node_modules:/app/node_modules
- ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
networks: [dezky]
depends_on:
mongo:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.portal.rule=Host(`app.dezky.local`) || Host(`dezky.local`)
- traefik.http.routers.portal.tls=true
- traefik.http.services.portal.loadbalancer.server.port=3000
# ─────────────────────────────────────────────────────────────────
# Provisioning service — NestJS worker for tenant lifecycle
# ─────────────────────────────────────────────────────────────────
provisioning:
image: node:20-alpine
container_name: dezky-provisioning
restart: unless-stopped
working_dir: /app
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm start:dev"
environment:
NODE_ENV: development
PORT: 3001
MONGODB_URI: mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin
AUTHENTIK_API_URL: http://authentik-server:9000/api/v3
AUTHENTIK_API_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
STALWART_API_URL: http://stalwart:8080
STALWART_ADMIN_USER: admin
STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD}
OCIS_API_URL: http://ocis:9200
volumes:
- ../../services/provisioning:/app
- provisioning_node_modules:/app/node_modules
networks: [dezky]
depends_on:
mongo:
condition: service_healthy
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
#
# Dezky local development bootstrap
# Run this once when setting up the project for the first time.
#
# Usage: ./scripts/bootstrap.sh
#
set -euo pipefail
# ────────────────────────────────────────
# Colors for output
# ────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
# ────────────────────────────────────────
# Determine project root
# ────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_DIR="$PROJECT_ROOT/infrastructure/docker-compose"
CERTS_DIR="$COMPOSE_DIR/certs"
cd "$PROJECT_ROOT"
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Dezky Local Development Bootstrap ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
# ────────────────────────────────────────
# Step 1: Check prerequisites
# ────────────────────────────────────────
info "Step 1: Checking prerequisites..."
check_command() {
if ! command -v "$1" &> /dev/null; then
error "$1 is not installed."
echo " Install with: $2"
exit 1
fi
ok "$1 found: $(command -v "$1")"
}
check_command docker "Install Docker Desktop or OrbStack from https://orbstack.dev"
check_command mkcert "brew install mkcert"
check_command openssl "Should be preinstalled on macOS"
if ! docker compose version &> /dev/null; then
error "Docker Compose v2 not available."
echo " Update Docker Desktop or install OrbStack."
exit 1
fi
ok "Docker Compose v2 available"
# Check Docker daemon is running
if ! docker info &> /dev/null; then
error "Docker daemon not running. Start Docker Desktop / OrbStack first."
exit 1
fi
ok "Docker daemon running"
echo ""
# ────────────────────────────────────────
# Step 2: Generate TLS certificates
# ────────────────────────────────────────
info "Step 2: Setting up TLS certificates..."
mkdir -p "$CERTS_DIR"
cd "$CERTS_DIR"
if [[ -f "dezky.local.pem" && -f "dezky.local-key.pem" ]]; then
ok "TLS certificates already exist in $CERTS_DIR"
else
info "Generating wildcard certificate for *.dezky.local..."
if ! mkcert -CAROOT &> /dev/null; then
warn "mkcert root CA not found. Running mkcert -install..."
mkcert -install
fi
mkcert "*.dezky.local" "dezky.local" "localhost" "127.0.0.1" "::1"
# Normalize filenames (mkcert adds counts)
mv ./_wildcard.dezky.local+*.pem dezky.local.pem 2>/dev/null || true
mv ./_wildcard.dezky.local+*-key.pem dezky.local-key.pem 2>/dev/null || true
ok "TLS certificates generated"
fi
cd "$PROJECT_ROOT"
echo ""
# ────────────────────────────────────────
# Step 3: Update /etc/hosts
# ────────────────────────────────────────
info "Step 3: Setting up /etc/hosts entries..."
HOSTS_ENTRIES=(
"dezky.local"
"app.dezky.local"
"auth.dezky.local"
"mail.dezky.local"
"files.dezky.local"
"office.dezky.local"
"meet.dezky.local"
"chat.dezky.local"
"traefik.dezky.local"
)
MISSING_ENTRIES=()
for entry in "${HOSTS_ENTRIES[@]}"; do
if ! grep -q "127.0.0.1[[:space:]]\+.*\b${entry}\b" /etc/hosts; then
MISSING_ENTRIES+=("$entry")
fi
done
if [[ ${#MISSING_ENTRIES[@]} -eq 0 ]]; then
ok "All /etc/hosts entries already present"
else
warn "Missing /etc/hosts entries: ${MISSING_ENTRIES[*]}"
echo ""
echo "Add the following line to /etc/hosts (requires sudo):"
echo ""
echo "127.0.0.1 ${HOSTS_ENTRIES[*]}"
echo ""
read -p "Add these entries automatically? (requires sudo) [y/N] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
HOSTS_LINE="127.0.0.1 ${HOSTS_ENTRIES[*]}"
echo "$HOSTS_LINE" | sudo tee -a /etc/hosts > /dev/null
ok "Added /etc/hosts entries"
else
warn "Skipping /etc/hosts setup — you must add entries manually before continuing"
fi
fi
echo ""
# ────────────────────────────────────────
# Step 4: Generate .env file
# ────────────────────────────────────────
info "Step 4: Setting up .env file..."
if [[ -f "$PROJECT_ROOT/.env" ]]; then
ok ".env file already exists"
else
info "Generating .env with secure random values..."
cp "$PROJECT_ROOT/.env.example" "$PROJECT_ROOT/.env"
# Replace all 'changeme_*' placeholders with actual random values
if [[ "$OSTYPE" == "darwin"* ]]; then
SED_INPLACE=(-i '')
else
SED_INPLACE=(-i)
fi
while IFS= read -r line; do
if [[ "$line" =~ ^([A-Z_]+)=changeme ]]; then
VAR_NAME="${BASH_REMATCH[1]}"
if [[ "$VAR_NAME" == "AUTHENTIK_SECRET_KEY" ]]; then
NEW_VALUE=$(openssl rand -hex 50)
else
NEW_VALUE=$(openssl rand -hex 32)
fi
sed "${SED_INPLACE[@]}" "s|^${VAR_NAME}=changeme.*|${VAR_NAME}=${NEW_VALUE}|" "$PROJECT_ROOT/.env"
fi
done < "$PROJECT_ROOT/.env.example"
ok ".env generated with secure random values"
warn "Default admin password generated. Check .env for AUTHENTIK_BOOTSTRAP_PASSWORD"
fi
echo ""
# ────────────────────────────────────────
# Step 5: Pull Docker images
# ────────────────────────────────────────
info "Step 5: Pulling Docker images (this may take a few minutes)..."
cd "$COMPOSE_DIR"
docker compose pull
ok "All images pulled"
echo ""
# ────────────────────────────────────────
# Step 6: Start the stack in stages
# ────────────────────────────────────────
info "Step 6: Starting services..."
info "Starting database layer (postgres, mongo, redis)..."
docker compose up -d postgres mongo redis
info "Waiting for databases to be healthy..."
sleep 10
for service in postgres mongo redis; do
until [[ "$(docker inspect --format='{{.State.Health.Status}}' dezky-$service 2>/dev/null)" == "healthy" ]]; do
echo -n "."
sleep 2
done
echo ""
ok "$service is healthy"
done
info "Starting Traefik reverse proxy..."
docker compose up -d traefik
info "Starting Authentik..."
docker compose up -d authentik-server authentik-worker
info "Waiting 30 seconds for Authentik to bootstrap..."
sleep 30
info "Starting application services..."
docker compose up -d stalwart ocis collabora
echo ""
ok "Stack started"
echo ""
# ────────────────────────────────────────
# Final instructions
# ────────────────────────────────────────
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Setup Complete ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo "Service URLs:"
echo " Portal: https://app.dezky.local"
echo " Authentik (auth): https://auth.dezky.local"
echo " Mail (admin): https://mail.dezky.local"
echo " Files (OCIS): https://files.dezky.local"
echo " Office: https://office.dezky.local"
echo " Traefik: https://traefik.dezky.local"
echo ""
echo "First-time Authentik admin login:"
echo " URL: https://auth.dezky.local/if/flow/initial-setup/"
echo " Email: admin@dezky.local"
echo " Password: (see AUTHENTIK_BOOTSTRAP_PASSWORD in .env)"
echo ""
echo "Next steps:"
echo " 1. Configure Authentik (see docs/AUTHENTIK-SETUP.md)"
echo " 2. Configure OCIS OIDC provider in Authentik"
echo " 3. Start building the portal in apps/portal/"
echo ""
echo "Useful commands:"
echo " Logs: docker compose -f $COMPOSE_DIR/docker-compose.yml logs -f [service]"
echo " Stop: docker compose -f $COMPOSE_DIR/docker-compose.yml down"
echo " Reset: $PROJECT_ROOT/scripts/reset.sh"
echo ""
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
#
# Reset the Dezky local stack — destroys all data, restarts fresh.
# Useful when something is broken and you want to start over.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_DIR="$(cd "$SCRIPT_DIR/../infrastructure/docker-compose" && pwd)"
echo ""
echo "⚠️ WARNING: This will DESTROY ALL local data:"
echo " - All databases"
echo " - All file uploads"
echo " - All Authentik configuration"
echo " - All mail in Stalwart"
echo ""
read -p "Are you absolutely sure? Type 'yes' to confirm: " CONFIRM
if [[ "$CONFIRM" != "yes" ]]; then
echo "Aborted."
exit 0
fi
cd "$COMPOSE_DIR"
echo "Stopping all services..."
docker compose down -v
echo "Removing dangling volumes..."
docker volume prune -f --filter "label=com.docker.compose.project=dezky" 2>/dev/null || true
echo ""
echo "Reset complete. Run scripts/bootstrap.sh to start over."
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@dezky/provisioning",
"version": "0.0.1",
"private": true,
"description": "Dezky tenant provisioning worker — NestJS",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"test": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-fastify": "^10.4.0",
"@nestjs/config": "^3.3.0",
"@nestjs/mongoose": "^10.1.0",
"mongoose": "^8.7.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/testing": "^10.4.0",
"@types/node": "^20.0.0",
"typescript": "^5.5.0",
"ts-node": "^10.9.2"
},
"packageManager": "pnpm@9.12.0"
}
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { MongooseModule } from '@nestjs/mongoose'
import { HealthController } from './health.controller.js'
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
MongooseModule.forRoot(process.env.MONGODB_URI ?? 'mongodb://localhost:27017/dezky'),
],
controllers: [HealthController],
})
export class AppModule {}
@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common'
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'dezky-provisioning',
timestamp: new Date().toISOString(),
}
}
}
+28
View File
@@ -0,0 +1,28 @@
// Dezky Provisioning Service — Entry point
// Handles tenant lifecycle: create, suspend, delete, billing webhooks.
import { NestFactory } from '@nestjs/core'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { AppModule } from './app.module.js'
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: true }),
)
app.enableCors({
origin: /^https:\/\/([a-z0-9-]+\.)?dezky\.local$/,
credentials: true,
})
const port = Number(process.env.PORT ?? 3001)
await app.listen(port, '0.0.0.0')
console.log(`Provisioning service listening on http://0.0.0.0:${port}`)
}
bootstrap().catch((err) => {
console.error('Failed to start provisioning service', err)
process.exit(1)
})
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"lib": ["ES2022"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declaration": true,
"removeComments": false,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}