Files
portainer-core/setup-new-host.md
T
jpmschweitzerandClaude Opus 4.5 f3d29089d6 config(npm): reorganize tatlock and webui domain mappings
Fix naming conflict where tatlock.schweitz.net incorrectly pointed to
Open WebUI. Now correctly maps:
- tatlock.schweitz.net → Tatlock API (port 8000) with Authentik SSO
- webui.schweitz.net → Open WebUI (port 82) with Authentik SSO
- Corresponding .schweitz.internal domains for programmatic access

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 00:03:55 +01:00

18 KiB

Setting Up a New SSL-Secured, Authentik-Protected Host

This guide documents how to add a new public-facing subdomain with SSL (Let's Encrypt) and Authentik forward authentication through Nginx Proxy Manager (NPM).

Prerequisites

Before starting, ensure:

  1. DNS A Record: Create an A record for <subdomain>.schweitz.net pointing to your public IP
  2. Container Running: The target service container is running and accessible
  3. Network: Container is on docker-dataplane network (or accessible from NPM)
  4. Authentik Running: Both Authentik server and outpost(s) are operational

Step 1: Authentik Configuration

1.1 Create a Proxy Provider

  1. Go to Authentik AdminApplicationsProviders
  2. Click Create → Select Proxy Provider
  3. Configure:
Setting Value
Name <subdomain>.schweitz.net proxy
Authorization flow default-provider-authorization-implicit-consent
Forward auth (single application) Selected
External host https://<subdomain>.schweitz.net

Leave other settings as defaults (token validity 24 hours is fine).

1.2 Create an Application

  1. Go to ApplicationsApplications
  2. Click Create
  3. Configure:
Setting Value
Name <Descriptive Name> (e.g., "Library Wiki")
Slug <subdomain>-schweitz-net
Provider Select the proxy provider created above
Launch URL https://<subdomain>.schweitz.net

1.3 Add Application to Outpost

  1. Go to ApplicationsOutposts
  2. Edit the standalone outpost (authentik-proxy on port 9443/9445)
  3. In Applications, add your new application to the selected list
  4. Click Update

Note

: If using both embedded and standalone outposts, add the application to whichever outpost your forward auth config points to (standalone at localhost:9445 in our setup).

Step 2: NPM Configuration

2.1 Create Proxy Host

In NPM Admin (port 8000 or via API):

Details Tab:

Setting Value
Domain Names <subdomain>.schweitz.net
Scheme http (usually)
Forward Hostname/IP Container name or IP (e.g., wiki or 192.168.86.149)
Forward Port Container's internal port (e.g., 3000)
Cache Assets Off (unless needed)
Block Common Exploits On
Websockets Support On

SSL Tab:

Setting Value
SSL Certificate Request a new SSL Certificate
Force SSL On
HTTP/2 Support On
HSTS Enabled On
Email for Let's Encrypt jpmschweitzer@gmail.com
Agree to TOS Yes

Advanced Tab:

Paste the following configuration:

# Increase buffer size for large headers from Authentik
proxy_buffers 8 16k;
proxy_buffer_size 32k;

# Forward authentication via standalone outpost
auth_request /outpost.goauthentik.io/auth/nginx;
error_page 401 = @goauthentik_proxy_signin;

# Capture auth response headers
auth_request_set $auth_cookie $upstream_http_set_cookie;
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;

# Forward auth headers to application
add_header Set-Cookie $auth_cookie;
proxy_set_header X-authentik-username $authentik_username;
proxy_set_header X-authentik-groups $authentik_groups;
proxy_set_header X-authentik-email $authentik_email;
proxy_set_header X-authentik-name $authentik_name;
proxy_set_header X-authentik-uid $authentik_uid;

# Outpost proxy location
location /outpost.goauthentik.io {
    proxy_pass https://localhost:9445/outpost.goauthentik.io;
    proxy_set_header Host $host;
    proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $http_host;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
}

# Signin redirect handler - MUST use relative path, not absolute URL
location @goauthentik_proxy_signin {
    internal;
    add_header Set-Cookie $auth_cookie;
    return 302 /outpost.goauthentik.io/start?rd=$request_uri;
}

Critical: The redirect in @goauthentik_proxy_signin MUST use a relative path (/outpost.goauthentik.io/start), NOT an absolute URL. Using https://auth.schweitz.net/outpost.goauthentik.io/start will cause a 404 error due to host header mismatch.

2.2 Save and Verify

  1. Click Save
  2. Verify nginx status shows online (no errors)
  3. If errors occur, check the Advanced config syntax

Step 3: Testing

3.1 Initial Test

  1. Open an incognito/private browser window
  2. Navigate to https://<subdomain>.schweitz.net
  3. You should be redirected to Authentik login (Google OAuth or local)
  4. After login, you should see your application

If already logged into another .schweitz.net service:

  1. Open a new tab (same browser, not incognito)
  2. Navigate to the new subdomain
  3. You should access the application without re-authenticating (SSO cookie shared across .schweitz.net)

Troubleshooting

Authentik 404 "Not Found" Error

Symptom: Redirected to auth.schweitz.net/outpost.goauthentik.io/start?rd=... but see 404

Causes & Fixes:

  1. Absolute URL in redirect (most common)

    • Fix: Change return 302 https://auth.schweitz.net/outpost.goauthentik.io/start... to return 302 /outpost.goauthentik.io/start...
  2. Application not added to outpost

    • Fix: Go to Authentik → Outposts → Edit standalone outpost → Add application
  3. Provider not linked to application

    • Fix: Edit application → Select the correct proxy provider

Nginx Config Error / nginx_online: false

Symptom: NPM shows nginx error when saving

Causes & Fixes:

  1. Undefined variable $connection_upgrade

    • Fix: Remove WebSocket lines (proxy_http_version, Upgrade, Connection headers) from advanced config. NPM handles WebSockets via the "Websockets Support" toggle.
  2. Syntax error in advanced config

    • Fix: Verify all brackets are closed, semicolons present

502 Bad Gateway

Symptom: Auth works but application shows 502

Causes & Fixes:

  1. Wrong forward host/port

    • Fix: Verify container name and port in NPM proxy host settings
    • Test: curl http://<forward_host>:<forward_port> from NPM container
  2. Container not on correct network

    • Fix: Ensure container is on docker-dataplane network
  3. Outpost not running

    • Check: docker ps | grep authentik-proxy
    • Check logs: docker logs authentik-proxy

SSL Certificate Not Provisioning

Symptom: Certificate request fails

Causes & Fixes:

  1. DNS not propagated

    • Fix: Wait for DNS propagation, verify with dig <subdomain>.schweitz.net
  2. Port 80 blocked

    • Fix: Ensure port 80 is open for Let's Encrypt HTTP-01 challenge

Redirect Loop

Symptom: Browser shows "too many redirects"

Causes & Fixes:

  1. Auth domain has forward auth enabled

    • Fix: auth.schweitz.net must NOT have forward auth (would create loop)
  2. Cookie domain mismatch

    • Fix: Verify Authentik cookie domain is .schweitz.net

Reference Files

File Purpose
stacks/authentik.yml Authentik server, worker, and standalone outpost
stacks/wiki.yml Example: Wiki.js container configuration
npm-configs/organizr-forward-auth.conf Legacy reference (has bugs, do not use directly)

Architecture Overview

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   User Browser  │────▶│  NPM (port 80/  │────▶│  Target Service │
│                 │     │  443)           │     │  (e.g., Wiki.js)│
└─────────────────┘     └────────┬────────┘     └─────────────────┘
                                 │
                                 │ auth_request
                                 ▼
                        ┌─────────────────┐     ┌─────────────────┐
                        │ Authentik       │────▶│ Authentik       │
                        │ Standalone      │     │ Server          │
                        │ Outpost (9445)  │     │ (auth.schweitz  │
                        └─────────────────┘     │ .net)           │
                                                └─────────────────┘

Flow:

  1. User requests https://<subdomain>.schweitz.net
  2. NPM's auth_request directive checks with Authentik outpost
  3. If not authenticated: redirect to Authentik login
  4. User authenticates (Google OAuth, etc.)
  5. Authentik sets session cookie for .schweitz.net
  6. User redirected back, auth_request succeeds
  7. NPM proxies request to target service

Setting Up an Internal Domain (*.schweitz.internal)

Internal domains provide LAN-accessible URLs without SSL or Authentik authentication, ideal for:

  • Programmatic API access (scripts, automation, healthchecks)
  • Local development and testing
  • Container-to-container communication via DNS
  • Avoiding port number confusion on LAN

Pattern

Every external <service>.schweitz.net domain has a corresponding <service>.schweitz.internal domain that:

  • Uses HTTP only (no SSL certificates)
  • Has NO Authentik forward auth
  • Points to the same backend service
  • Resolves via /etc/hosts on the server

Current Internal Domains

Internal Domain Backend External Equivalent
home.schweitz.internal localhost:9999 home.schweitz.net
media.schweitz.internal localhost:8096 media.schweitz.net
cloud.schweitz.internal localhost:8082 cloud.schweitz.net
api.schweitz.internal localhost:8083 api.schweitz.net
code.schweitz.internal localhost:8084 code.schweitz.net
amp.schweitz.internal localhost:8080 amp.schweitz.net
housekeeping.schweitz.internal localhost:8123 housekeeping.schweitz.net
documents.schweitz.internal 192.168.86.149:8091 documents.schweitz.net
git.schweitz.internal localhost:3002 git.schweitz.net
library.schweitz.internal localhost:8088 library.schweitz.net
tatlock.schweitz.internal localhost:8000 tatlock.schweitz.net
webui.schweitz.internal localhost:82 webui.schweitz.net

Step 1: Add DNS Entry

Add the internal domain to /etc/hosts on the tower-of-joy server:

# Add to /etc/hosts
192.168.86.149 <service>.schweitz.internal

Step 2: Create NPM Proxy Host

Via NPM UI (port 8000):

Details Tab:

Setting Value
Domain Names <service>.schweitz.internal
Scheme http
Forward Hostname/IP Same as external domain (e.g., localhost)
Forward Port Same as external domain
Cache Assets On (optional)
Block Common Exploits On
Websockets Support On (if needed)

SSL Tab: Leave empty (no SSL for internal domains)

Advanced Tab: Leave empty (no Authentik forward auth)

Via NPM API:

# Get token
TOKEN=$(curl -s -X POST http://localhost:81/api/tokens \
  -H "Content-Type: application/json" \
  -d '{"identity": "<email>", "secret": "<password>"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")

# Create internal proxy host
curl -s -X POST http://localhost:81/api/nginx/proxy-hosts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "domain_names": ["<service>.schweitz.internal"],
    "forward_scheme": "http",
    "forward_host": "localhost",
    "forward_port": <port>,
    "access_list_id": 0,
    "certificate_id": 0,
    "ssl_forced": false,
    "caching_enabled": true,
    "block_exploits": true,
    "advanced_config": "",
    "allow_websocket_upgrade": true,
    "http2_support": false,
    "hsts_enabled": false,
    "hsts_subdomains": false,
    "enabled": true,
    "locations": []
  }'

Step 3: Test

curl -s -o /dev/null -w "%{http_code}" http://<service>.schweitz.internal/

Expected: 200, 302 (redirect), or service-specific response.

Usage Examples

# API calls without auth
curl http://api.schweitz.internal/health

# Git operations (Docker image pulls)
docker pull git.schweitz.internal/jpmschweitzer/core-api:latest

# Healthchecks in Docker Compose
healthcheck:
  test: ["CMD-SHELL", "curl -fSs http://git.schweitz.internal/api/healthz"]

Appendix: Portainer API for Remote Stack Deployment

When you cannot access the Portainer web UI (e.g., outside home network), you can deploy stacks via the API.

Authentication

# Get JWT token (valid for 8 hours)
http --ignore-stdin POST http://192.168.86.149:8001/api/auth \
  username=admin password=<password>

# Response: {"jwt":"eyJ..."}

List Stacks

http --ignore-stdin GET http://192.168.86.149:8001/api/stacks \
  "Authorization:Bearer <token>"

Read Stack File

http --ignore-stdin GET "http://192.168.86.149:8001/api/stacks/<stack_id>/file" \
  "Authorization:Bearer <token>"

Create Stack

Endpoint: POST /api/stacks/create/standalone/string?endpointId=3

Payload format:

{
  "name": "stack-name",
  "stackFileContent": "version: '3.8'\nservices:\n  ...",
  "env": [
    {"name": "VAR_NAME", "value": "var_value"},
    {"name": "SECRET_KEY", "value": "secret_value"}
  ]
}

Example deployment script:

#!/bin/bash
# Get token
TOKEN=$(http --ignore-stdin POST http://192.168.86.149:8001/api/auth \
  username=admin password=<password> | jq -r '.jwt')

# Read stack file and create JSON payload
STACK_CONTENT=$(cat /path/to/stack.yml)
jq -n \
  --arg name "my-stack" \
  --arg content "$STACK_CONTENT" \
  '{
    name: $name,
    stackFileContent: $content,
    env: [
      {name: "DB_PASSWORD", value: "secret123"},
      {name: "API_KEY", value: "key456"}
    ]
  }' > /tmp/payload.json

# Deploy
curl -s -X POST "http://192.168.86.149:8001/api/stacks/create/standalone/string?endpointId=3" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @/tmp/payload.json

Update Stack

curl -s -X PUT "http://192.168.86.149:8001/api/stacks/<stack_id>?endpointId=3" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @/tmp/payload.json

Delete Stack

http --ignore-stdin DELETE "http://192.168.86.149:8001/api/stacks/<stack_id>?endpointId=3" \
  "Authorization:Bearer <token>"

Key Notes

  • Endpoint ID: Use 3 for the local Docker environment (verify with GET /api/endpoints)
  • Stack Type: Use standalone/string for Docker Compose stacks (not Swarm)
  • Environment Variables: Passed as array of {name, value} objects, referenced in compose as ${VAR_NAME}
  • Token Expiry: JWT tokens expire after 8 hours; re-authenticate if needed

Appendix: NPM API for Remote Proxy Configuration

When you cannot access the NPM web UI, you can configure proxy hosts via the API (port 81).

Authentication

http --ignore-stdin POST http://192.168.86.149:81/api/tokens \
  identity=<email> secret=<password>

# Response: {"token":"eyJ...", "expires":"..."}

List Proxy Hosts

http --ignore-stdin GET http://192.168.86.149:81/api/nginx/proxy-hosts \
  "Authorization:Bearer <token>"

Create Proxy Host (without SSL)

curl -s -X POST http://192.168.86.149:81/api/nginx/proxy-hosts \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "domain_names": ["subdomain.schweitz.net"],
    "forward_scheme": "http",
    "forward_host": "192.168.86.149",
    "forward_port": 8091,
    "access_list_id": 0,
    "certificate_id": 0,
    "ssl_forced": false,
    "caching_enabled": false,
    "block_exploits": true,
    "advanced_config": "",
    "allow_websocket_upgrade": true,
    "http2_support": false,
    "hsts_enabled": false,
    "hsts_subdomains": false,
    "enabled": true,
    "locations": []
  }'

Request Let's Encrypt Certificate

curl -s -X POST http://192.168.86.149:81/api/nginx/certificates \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "domain_names": ["subdomain.schweitz.net"],
    "meta": {"dns_challenge": false},
    "provider": "letsencrypt"
  }'

# Response includes certificate ID

Update Proxy Host with SSL

curl -s -X PUT http://192.168.86.149:81/api/nginx/proxy-hosts/<proxy_id> \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "domain_names": ["subdomain.schweitz.net"],
    "forward_scheme": "http",
    "forward_host": "192.168.86.149",
    "forward_port": 8091,
    "access_list_id": 0,
    "certificate_id": <cert_id>,
    "ssl_forced": true,
    "caching_enabled": false,
    "block_exploits": true,
    "advanced_config": "",
    "allow_websocket_upgrade": true,
    "http2_support": true,
    "hsts_enabled": true,
    "hsts_subdomains": false,
    "enabled": true,
    "locations": []
  }'

Key Notes

  • API Port: NPM API is on port 81, not 80/443
  • Token Expiry: Tokens expire after 1 day
  • SSL Flow: Create proxy host → Request certificate → Update proxy host with certificate_id
  • forward_host: Use container name (if on same network) or host IP
  • Verify: Check meta.nginx_online: true in response

Last Updated: 2025-12-30 Based on: library.schweitz.net setup session, Paperless-ngx API deployment, Internal domain consolidation, Tatlock/WebUI domain reorganization