Expand from initial setup notes to full operational manual covering OpenClaw gateway, Palmr file sharing, MinIO storage, Deepgram MCP, Gitea auto-deploy workflows, Tailscale Funnel architecture, JSX artifact publishing, and OpenWrt router management. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
61 KiB
NUC Server - Claude Code Instructions
Server Access
The NUC server is accessible via SSH:
ssh nuc
Connection Details:
- Hostname:
192.168.1.3(local) or100.113.153.45(Tailscale) - User:
alezmad - SSH Key:
~/.ssh/id_ed25519_nuc
DNS & Tailscale Setup
DNS Strategy: Local IP + Tailscale Subnet Routing
All .nuc.lan domains resolve to the local LAN IP (192.168.1.3). This allows any device on the LAN to access services without Tailscale.
Remote access (Tailscale) works via two mechanisms:
- Split DNS: Tailscale forwards
*.nuc.lanqueries to the OpenWrt router (192.168.1.1) through the tunnel - Subnet routing: The NUC advertises
192.168.1.0/24as a Tailscale subnet route, so192.168.1.3is reachable from any Tailscale device remotely
Configured Domains (OpenWrt Router DNS)
| Domain | Resolves To | Service |
|---|---|---|
nuc.lan |
192.168.1.3 |
NUC Portal |
nuc.local |
192.168.1.3 |
NUC Portal |
coolify.nuc.lan |
192.168.1.3 |
Coolify |
gitea.nuc.lan |
192.168.1.3 |
Gitea |
outline.nuc.lan |
192.168.1.3 |
Outline Wiki |
files.nuc.lan |
192.168.1.3 |
FileBrowser |
mail.nuc.lan |
192.168.1.3 |
Snappymail |
vault.nuc.lan |
192.168.1.3 |
Vaultwarden |
homepage.nuc.lan |
192.168.1.3 |
NUC Portal |
brand.nuc.lan |
192.168.1.3 |
Whyrating Brand |
templates.nuc.lan |
192.168.1.3 |
Whyrating Templates |
whyrating.nuc.lan |
192.168.1.3 |
Whyrating Hub |
whyops.nuc.lan |
192.168.1.3 |
WhyOps (scraping, pipelines, testing) |
arrio.nuc.lan |
192.168.1.3 |
Arrio (hotel check-in) |
Traefik Routing (Dynamic Config)
Traefik routes domain-based requests to the correct backend. Config location: /data/coolify/proxy/dynamic/nuc-services.yaml
# Routes for port-based services via domain names
http:
routers:
coolify:
rule: Host(`coolify.nuc.lan`)
service: coolify
services:
coolify:
loadBalancer:
servers:
- url: http://host.docker.internal:8000
Adding a New Domain
# 1. Add DNS entry on router (via NUC jump host)
ssh nuc "ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 '
uci add dhcp domain
uci set dhcp.@domain[-1].name=\"newservice.nuc.lan\"
uci set dhcp.@domain[-1].ip=\"192.168.1.3\"
uci commit dhcp
/etc/init.d/dnsmasq restart
'"
# 2. Add Traefik route (if needed for port-based service)
# Edit /data/coolify/proxy/dynamic/nuc-services.yaml
Always-On Tailscale
Keep Tailscale running - it's designed to be always-on:
- When on home network: Uses direct connection (no relay, same performance as local)
- When remote: Routes through Tailscale mesh
- Minimal resource usage (~0% CPU when idle)
Service Management
Coolify (Primary Service Manager)
All services are managed through Coolify at http://coolify.nuc.lan (or http://100.113.153.45:8000)
Prefer using Coolify MCP (mcp__coolify__*) for service management - it's faster and more reliable than SSH commands.
⚠️ STRICT RULE: Container Deployment Priority
ALWAYS attempt Coolify first when adding any container/service:
- First: Try
mcp__coolify__service(action="create", type="<service-name>", ...) - If type invalid: Deploy via docker-compose in Coolify using
docker_compose_raw - Last resort: Direct Docker commands via SSH (only if Coolify can't handle it)
# Step 1: Try native Coolify service type
mcp__coolify__service(action="create", type="servicename", name="ServiceName",
server_uuid="qk84w0goo4w48g4ggsoo0oss", project_uuid="a8484ggc88c40w4g4k004ow0",
environment_name="production", instant_deploy=True)
# Step 2: If "Invalid service type", use docker-compose
mcp__coolify__service(action="create", name="ServiceName",
server_uuid="qk84w0goo4w48g4ggsoo0oss", project_uuid="a8484ggc88c40w4g4k004ow0",
environment_name="production",
docker_compose_raw="""
services:
myservice:
image: organization/image:tag
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- data:/app/data
volumes:
data:
""", instant_deploy=True)
Why Coolify first:
- Centralized management in one UI
- Automatic restarts, health checks
- Easy updates and rollbacks
- Visible in infrastructure overview
- Consistent with existing services
⚠️ STRICT RULE: Browser MCP for Manual Configurations
When a task requires manual web UI interaction (OAuth setup, API key generation, admin consoles), ALWAYS use Browser MCP instead of asking the user to do it manually.
Priority order for browser automation:
mcp__playwriter-nuc-01__*- Remote NUC browser (preferred, no local resources)mcp__chrome-devtools-nuc-01__*- Chrome DevTools for NUC browsermcp__playwriter-local__*- Local browser (fallback, uses local resources)
Browser MCP Naming Convention:
playwriter-<location>-<id>
chrome-devtools-<location>-<id>
Examples:
- playwriter-local → Local machine browser
- playwriter-nuc-01 → First NUC browser container
- playwriter-nuc-02 → Second NUC browser (future)
- playwriter-cloud-01 → Cloud browser instance (future)
Common use cases:
- Generating API keys/tokens (Tailscale, OAuth apps, etc.)
- Configuring OAuth/OIDC providers
- Admin console settings not available via API
- Any "go to website and click" tasks
# Example: Navigate and interact
mcp__chrome-devtools__navigate_page(type="url", url="https://admin.example.com")
mcp__chrome-devtools__take_snapshot() # See current state
mcp__chrome-devtools__click(uid="<element_uid>")
mcp__chrome-devtools__fill(uid="<element_uid>", value="text")
NEVER say "please go to X and do Y manually" - use browser MCP instead.
⚠️ STRICT RULE: Parallel Subtasks for Multiple Operations
When multiple independent services, configurations, or entities need to be set up, ALWAYS use parallel Task agents instead of sequential operations.
# WRONG - Sequential (slow)
# Step 1: Configure Tailscale
# Step 2: Configure WireGuard
# Step 3: Add to Homepage
# CORRECT - Parallel subtasks (fast)
Task(subagent_type="general-purpose", prompt="Configure Tailscale auth key...", description="Setup Tailscale")
Task(subagent_type="general-purpose", prompt="Install WireGuard on router...", description="Setup WireGuard")
Task(subagent_type="general-purpose", prompt="Add services to Homepage...", description="Update Homepage")
# All three run simultaneously!
When to parallelize:
- Multiple service deployments
- Multiple configuration changes across different systems
- Independent API calls or browser automations
- Any tasks that don't depend on each other's output
How to parallelize:
- Use multiple
Tasktool calls in a single message - Each task gets its own agent with full context
- Results are collected when all complete
Available MCPs for NUC Management
| MCP | Purpose |
|---|---|
mcp__coolify__* |
Service management, deployments, env vars |
mcp__stalwart-mail__* |
Email server management (users, domains, queue) |
mcp__email-client__* |
Read/send emails via IMAP/SMTP (see below) |
mcp__nocodb__* |
Database operations, table management |
mcp__ssh-manager__* |
Direct SSH commands, file transfers |
mcp__n8n__* |
Workflow automation (if configured) |
mcp__playwriter__* |
Browser automation fallback (see below) |
mcp__deepgram__* |
Audio transcription (STT) and text-to-speech (TTS) |
Stalwart Mail MCP (Quick Guide)
Location: ~/mcp-servers/stalwart-mail/
Manage the self-hosted Stalwart mail server via natural language.
Available Tools:
| Category | Tools |
|---|---|
| Users | list_users, get_user, create_user, update_user_password, delete_user, add_email_alias |
| Domains | create_domain, generate_dkim |
| Queue | list_queue, get_queue_status, delete_queued_message, retry_queued_message |
| Monitoring | get_metrics, get_dmarc_reports, get_server_logs |
| DNS | check_dns_records, troubleshoot_delivery |
| Spam | train_spam, train_ham, update_spam_filter |
Usage Examples:
"List all mail users"
"Create user sales with email sales@whyrating.com and password Secret123"
"Check the mail queue"
"Verify DNS records for whyrating.com"
"Show server metrics"
"Delete user john"
Direct API Test (if MCP not responding):
curl -s -u "admin:QfKYjCJdxu" "http://192.168.1.3:8081/api/principal" | jq .
Reconfigure MCP:
claude mcp remove stalwart-mail
claude mcp add stalwart-mail \
-e STALWART_URL=http://192.168.1.3:8081 \
-e STALWART_USER=admin \
-e STALWART_PASS=QfKYjCJdxu \
--scope user \
-- ~/mcp-servers/stalwart-mail/.venv/bin/python ~/mcp-servers/stalwart-mail/server.py
⚠️ SMTP Authentication Requirements:
- Password format: Must be SHA-512 hashed (not plaintext). When creating users via API:
import crypt hashed = crypt.crypt('password', crypt.mksalt(crypt.METHOD_SHA512)) # Use hashed value in 'secrets' field - SMTP login: Use username only (e.g.,
info), NOT full email (info@whyrating.com) - Port 465 (SMTPS): Supports PLAIN/LOGIN auth with implicit TLS
- Port 587 (Submission): Requires STARTTLS, only OAuth supported without TLS
Send email via Python (from NUC):
import smtplib, ssl
from email.mime.text import MIMEText
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
with smtplib.SMTP_SSL('localhost', 465, context=context) as server:
server.login('info', 'whyrating2026') # Username only!
server.sendmail('info@whyrating.com', 'recipient@example.com', msg.as_string())
Email Client MCP (Read/Send Emails)
Package: mcp-email-server
Read and send emails via IMAP/SMTP directly from Claude.
Configured for: info@whyrating.com on Stalwart
Usage Examples:
"Check my inbox"
"Read the latest email"
"Send an email to john@example.com with subject Hello"
"Search emails from support@"
"List email folders"
Reconfigure:
claude mcp remove email-client
claude mcp add email-client \
-e MCP_EMAIL_SERVER_EMAIL_ADDRESS=info@whyrating.com \
-e MCP_EMAIL_SERVER_PASSWORD=whyrating2026 \
-e MCP_EMAIL_SERVER_IMAP_HOST=192.168.1.3 \
-e MCP_EMAIL_SERVER_IMAP_PORT=143 \
-e MCP_EMAIL_SERVER_SMTP_HOST=192.168.1.3 \
-e MCP_EMAIL_SERVER_SMTP_PORT=587 \
-e MCP_EMAIL_SERVER_SMTP_VERIFY_SSL=false \
-e MCP_EMAIL_SERVER_ENABLE_ATTACHMENT_DOWNLOAD=true \
--scope user \
-- uvx mcp-email-server@latest stdio
Adding Remote MCP Servers (HTTP Transport)
Use claude mcp add --transport http for remote MCP endpoints - this is the recommended method for services with native MCP support.
# Basic syntax
claude mcp add --transport http <name> <url> --scope user --header "<header>"
# Example: NocoDB MCP (globally available)
claude mcp add --transport http nocodb http://192.168.1.3:8084/mcp/ncnyir1cy6n9bf5p \
--scope user \
--header "xc-mcp-token: qjjAXRxuYzRtEn-cA4lbPFi5km_pojTX"
Scope options:
--scope user- Available across all projects (stored in~/.claude.json)--scope local- Current project only (default)--scope project- Shared via.mcp.json(committed to repo)
Why CLI over JSON config:
- JSON config with
mcp-remoteoften fails to load tools - CLI
--transport httphandles HTTP endpoints natively - No need for
--allow-httpflag or other workarounds
Managing MCP servers:
claude mcp list # List all configured servers
claude mcp get nocodb # Get details for specific server
claude mcp remove nocodb # Remove a server
/mcp # Check status in Claude Code
⚠️ STRICT RULE: MCP Research Protocol
When asked to find/recommend new MCPs, read the detailed guide at docs/mcp-research-guide.md which contains:
- MCP directories (MCP.so, Smithery, MCPHub, etc.)
- GitHub verification resources
- Evaluation criteria checklist
- Research workflow
- Category quick reference
Quick workflow:
- Search directories:
mcp.so,smithery.ai,mcpservers.org - Verify on GitHub: stars, last commit, issues
- Evaluate: 50+ stars, <6 months active, 3+ tools, OSS license
Playwriter as Fallback
When SSH, API endpoints, or other MCPs can't accomplish a task (e.g., no API available, UI-only settings), use Playwriter MCP to automate browser interactions:
// Navigate to service UI
await page.goto('http://192.168.1.3:8000');
// Get page state
console.log(await accessibilitySnapshot({ page }));
// Interact with elements
await page.locator('aria-ref=e5').click();
Use cases:
- Configuring services that lack APIs (Coolify UI settings, etc.)
- Creating OAuth apps, API keys through web interfaces
- Debugging issues by inspecting service dashboards
- Any task where clicking through a UI is the only option
Remote Browser Container (NUC)
A dedicated browser container runs on the NUC for AI-controlled browsing without local resources:
Access:
- noVNC Web:
http://192.168.1.3:6081/vnc.html - Playwriter Relay:
ws://192.168.1.3:19988 - Chrome DevTools:
http://192.168.1.3:9222
MCP connects remotely via:
{
"playwriter-nuc-01": {
"_id": "nuc-01",
"_host": "192.168.1.3",
"args": ["playwriter", "--host", "ws://192.168.1.3:19988", "--token", "nuc-browser-token"]
}
}
First-time setup: Access noVNC, install Playwriter extension, click to activate (turns green).
Container location: ~/playwriter-browser/ on NUC (deployed via docker compose)
Coolify CLI Commands:
# Access Coolify's Laravel tinker for direct database/service manipulation
ssh nuc "docker exec coolify php artisan tinker --execute=\"<PHP_CODE>\""
# Restart a service (example for service ID 9 - Outline)
ssh nuc "docker exec coolify php artisan tinker --execute=\"
use App\Actions\Service\StartService;
use App\Models\Service;
\\\$service = Service::find(9);
StartService::run(\\\$service);
\""
# Update environment variable (encrypted)
ssh nuc "docker exec coolify php artisan tinker --execute=\"
use App\Models\EnvironmentVariable;
\\\$var = EnvironmentVariable::where('key', 'VAR_NAME')->where('resourceable_id', SERVICE_ID)->first();
\\\$var->value = encrypt('new_value');
\\\$var->save();
\""
Docker Commands
# List all containers
ssh nuc "docker ps -a --format '{{.Names}}\t{{.Status}}'"
# View container logs
ssh nuc "docker logs <container_name> 2>&1 | tail -50"
# Restart a container
ssh nuc "docker restart <container_name>"
# Execute command in container
ssh nuc "docker exec <container_name> <command>"
Services & Ports
Preferred access via domain names (works from anywhere via Tailscale):
| Service | Domain | Port-based URL | Container |
|---|---|---|---|
| NUC Portal | http://nuc.lan |
- | nuc-portal-* |
| Coolify | http://coolify.nuc.lan |
http://100.113.153.45:8000 |
coolify |
| Gitea | http://gitea.nuc.lan |
http://100.113.153.45:3030 |
gitea-* |
| Outline | http://outline.nuc.lan |
http://100.113.153.45:3080 |
outline-* |
| FileBrowser | http://files.nuc.lan |
http://100.113.153.45:8085 |
filebrowser-* |
| Snappymail | http://mail.nuc.lan |
http://100.113.153.45:8082 |
snappymail-* |
| Vaultwarden | http://vault.nuc.lan |
http://100.113.153.45:8222 |
vaultwarden-* |
| Homepage | http://homepage.nuc.lan |
http://100.113.153.45:3000 |
nuc-portal-* |
| NocoDB | - | http://100.113.153.45:8084 |
nocodb-* |
| n8n | - | http://100.113.153.45:5678 |
n8n-* |
| Ntfy | - | http://100.113.153.45:8333 |
ntfy-* |
| MinIO Console | - | http://100.113.153.45:9001 |
minio-* |
| MinIO API | - | http://100.113.153.45:9000 |
minio-* |
| Authentik | - | http://100.113.153.45:9090 |
authentik-* |
| Adminer | - | http://100.113.153.45:8088 |
adminer |
| Uptime Kuma | - | http://100.113.153.45:3001 |
uptime-kuma |
| Kopia | - | http://100.113.153.45:51515 |
kopia |
| Dozzle | - | http://100.113.153.45:9999 |
dozzle |
| CloudBeaver | - | http://100.113.153.45:8978 |
cloudbeaver-* |
| WhyOps | http://whyops.nuc.lan |
http://100.113.153.45:3002 |
whyrating-dashboard |
| Palmr | https://alezmad-nuc.tail58f5ad.ts.net:8443 |
http://100.113.153.45:3334 |
palmr |
| Arrio | http://arrio.nuc.lan |
http://100.113.153.45:3335 |
web-tgksg0s8gocko4csggs0808c |
| Deepgram MCP | - | http://100.113.153.45:8009 |
deepgram-mcp |
Note: Use Tailscale IP (100.113.153.45) instead of 192.168.1.3 to avoid subnet conflicts when remote.
Port Forwarding
Some services use port forwarding containers (alpine/socat or nginx) to expose internal Coolify services:
# Create a port forwarder for a Coolify service
ssh nuc "docker run -d --name port-fwd-<service> --network <coolify_network> -p <external_port>:<internal_port> alpine/socat tcp-listen:<internal_port>,fork,reuseaddr tcp-connect:<container_name>:<container_port>"
Configuration Files
Homepage Config:
- Location:
/opt/homepage/config/ - Services:
/opt/homepage/config/services.yaml
Coolify Data:
- Location:
/data/coolify/
Important Notes
-
After Coolify redeploy: Containers may be in "Created" state - manually start with
docker start <container> -
Environment Variables in Coolify: Are encrypted with Laravel encryption. Use
encrypt()when updating. -
HSTS Issues: Some services send HSTS headers. Use nginx proxy with
proxy_hide_header Strict-Transport-Security;to strip them. -
Network Discovery: Find container's network with
docker inspect <container> --format '{{.NetworkSettings.Networks}}'
Troubleshooting
Coolify MCP vs Direct Docker
Always verify Coolify status with Docker - Coolify's status can lag behind actual container state:
# Coolify may show "exited" but container is actually running
ssh nuc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep <service>"
Common Issues
-
Containers stuck in "Created" state: After Coolify restart/redeploy, containers may not auto-start
ssh nuc "docker start <container_name>" -
Service shows "running:unknown": No healthcheck configured. Add one via Coolify service update:
healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:<port>"] interval: 30s timeout: 10s retries: 3 start_period: 30s -
Service dependencies not starting: Services with
depends_on: condition: service_healthywon't start until dependencies are healthy. Check dependency containers first. -
Stale database entries in Coolify: Coolify may have database/service entries with no corresponding container. Safe to delete if container doesn't exist:
# Verify container doesn't exist ssh nuc "docker ps -a | grep <container_name>" # Then delete via Coolify MCP or UI -
Embedded vs Standalone databases: Services like Outline and Authentik have their own PostgreSQL containers (e.g.,
postgres-pccg80...) bundled in the service compose. These are separate from standalone Coolify databases. -
Wrong healthcheck endpoint: Some services use
/healthzinstead of/. Verify with:ssh nuc "docker exec <container> wget -qO- http://127.0.0.1:<port>/healthz" -
localhost resolves to IPv6 on NUC: Docker containers bind to IPv4 only. Always use
127.0.0.1instead oflocalhostin Tailscale Funnel/Serve targets, curl commands inside SSH, etc. -
Tailscale Funnel port not working externally: Funnel ONLY supports ports 443, 8443, and 10000. Other ports will show as "Funnel on" in status but won't route internet traffic.
-
Creating API keys when no UI available (e.g., n8n):
# Stop container, insert directly into SQLite, restart ssh nuc "docker run --rm -v <volume>:/data keinos/sqlite3 sqlite3 /data/database.sqlite \"<INSERT_QUERY>\""
Coolify MCP Quick Reference
# Check infrastructure overview
mcp__coolify__get_infrastructure_overview()
# Start/stop/restart service
mcp__coolify__control(resource="service", action="start|stop|restart", uuid="<uuid>")
# Get service details (including docker_compose)
mcp__coolify__get_service(uuid="<uuid>")
# Update service config (e.g., add healthcheck)
mcp__coolify__service(action="update", uuid="<uuid>", docker_compose_raw="<yaml>")
# Delete stale database
mcp__coolify__database(action="delete", uuid="<uuid>", delete_volumes=True)
OpenWrt Router
The network is managed by an OpenWrt router at 192.168.1.1.
SSH Access
# Connect to router
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1
# Or via NUC as jump host
ssh nuc "ssh root@192.168.1.1 '<command>'"
Router Details:
- IP:
192.168.1.1 - User:
root - SSH Key:
~/.ssh/id_ed25519_nuc - Firmware: OpenWrt 23.05.0
- Architecture: ARM Cortex-A9 (mvebu/cortexa9)
- LuCI Web UI:
http://192.168.1.1
OpenWrt MCP Server
An MCP server runs on the router for AI integration:
- Location:
/opt/mcp-server/openwrt-mcp-server - Config:
/opt/mcp-server/config.toml - HTTP API:
http://192.168.1.1:8090 - API Token:
openwrt-mcp-secret-2026 - Init Script:
/etc/init.d/mcp-server
# Control MCP server
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "/etc/init.d/mcp-server start|stop|restart"
Common Router Tasks
Port Forwarding:
# List current port forwards
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "uci show firewall | grep redirect"
# Add port forward
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "
uci add firewall redirect
uci set firewall.@redirect[-1].name='<name>'
uci set firewall.@redirect[-1].src='wan'
uci set firewall.@redirect[-1].src_dport='<external_port>'
uci set firewall.@redirect[-1].dest='lan'
uci set firewall.@redirect[-1].dest_ip='<internal_ip>'
uci set firewall.@redirect[-1].dest_port='<internal_port>'
uci set firewall.@redirect[-1].proto='tcp udp'
uci set firewall.@redirect[-1].target='DNAT'
uci commit firewall
/etc/init.d/firewall restart
"
Firewall Rules:
# Show firewall zones
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "uci show firewall | grep zone"
# Allow traffic from WAN to specific port
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "
uci add firewall rule
uci set firewall.@rule[-1].name='Allow-<service>'
uci set firewall.@rule[-1].src='wan'
uci set firewall.@rule[-1].dest_port='<port>'
uci set firewall.@rule[-1].proto='tcp'
uci set firewall.@rule[-1].target='ACCEPT'
uci commit firewall
/etc/init.d/firewall restart
"
DNS/DHCP (dnsmasq):
# View DNS/DHCP config
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "uci show dhcp"
# Force DNS cache refresh
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "/etc/init.d/dnsmasq restart"
# Add static DHCP lease
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "
uci add dhcp host
uci set dhcp.@host[-1].name='<hostname>'
uci set dhcp.@host[-1].mac='<mac_address>'
uci set dhcp.@host[-1].ip='<ip_address>'
uci commit dhcp
/etc/init.d/dnsmasq restart
"
# Add custom DNS entry
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "
uci add dhcp domain
uci set dhcp.@domain[-1].name='<hostname>'
uci set dhcp.@domain[-1].ip='<ip_address>'
uci commit dhcp
/etc/init.d/dnsmasq restart
"
Network Diagnostics:
# Check WAN status
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "ifstatus wan"
# View connected clients
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "cat /tmp/dhcp.leases"
# Check routing table
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "ip route"
# View system logs
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "logread | tail -50"
Package Management (opkg):
# Update package lists
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "opkg update"
# Install package
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "opkg install <package>"
# List installed packages
ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "opkg list-installed"
Browser Automation (Chrome DevTools MCP): When SSH commands aren't sufficient, use Chrome DevTools MCP to automate LuCI:
# Navigate to router UI
mcp__chrome-devtools__navigate_page(type="url", url="http://192.168.1.1/cgi-bin/luci/admin/...")
# Take snapshot of UI state
mcp__chrome-devtools__take_snapshot()
# Interact with elements by uid
mcp__chrome-devtools__click(uid="<uid>")
mcp__chrome-devtools__fill(uid="<uid>", value="<text>")
Authentication
Outline OIDC (via Gitea):
- Client ID:
249a3a1d-92d4-47d8-b4a9-81c64e1da6ab - Auth URL:
http://192.168.1.3:3030/login/oauth/authorize - Token URL:
http://192.168.1.3:3030/login/oauth/access_token - Userinfo URL:
http://192.168.1.3:3030/login/oauth/userinfo
⚠️ Critical Credentials & Access
CloudBeaver (Database Manager)
| Property | Value |
|---|---|
| URL | http://192.168.1.3:8978 |
| Admin User | cbadmin |
| Admin Password | CloudBeaver2026! |
| Service UUID | joo4g4k0w08k8kcosgsgswc0 |
Pre-configured connections: 9 databases across 3 folders. Turbostarter DB is now in service v4gogwwc8wkk4888ksscc4k4 (container: db-v4gogwwc8wkk4888ksscc4k4).
Connected to 7 Docker networks for direct container-to-container access.
Vaultwarden (Password Manager)
⚠️ CRITICAL: Vaultwarden REQUIRES HTTPS - The Web Crypto API needs a secure context for client-side encryption. HTTP access will NOT work (blank page/loading forever).
| Property | Value |
|---|---|
| HTTPS URL | https://nuc-tailscale.tail58f5ad.ts.net:8443 |
| HTTP URL | http://192.168.1.3:8222 (won't load - HTTPS required) |
| Admin Email | admin@nuc.lan |
| Admin Password | NucVault2026!Secure |
Access via Tailscale Funnel:
# Vaultwarden is exposed on port 8443 via Tailscale Funnel
open "https://nuc-tailscale.tail58f5ad.ts.net:8443"
Stalwart Mail Server
| Property | Value |
|---|---|
| Admin URL | http://192.168.1.3:8081 |
| Username | admin |
| Password | QfKYjCJdxu |
| Webmail (Snappymail) | http://192.168.1.3:8082 |
| Service UUID | kw00kok0w0s8gcok008gk04k |
| MCP Server | mcp__stalwart-mail__* (see Quick Guide above) |
Mail Users:
| Username | Password | |
|---|---|---|
info@whyrating.com |
info |
whyrating2026 |
DNS Records Configured: SPF, DKIM (Ed25519 + RSA), DMARC, MX for whyrating.com
Container Status: Running
Gitea Users (for Outline OIDC)
| Username | Password | Notes |
|---|---|---|
nedas |
NedasNUC2026! |
Regular user account |
Gitea-Coolify Integration (Git Auto-Deploy)
Deploy Next.js apps from self-hosted Gitea with auto-deploy on push. Full docs: docs/gitea-coolify-auto-deploy.md
⚠️ CRITICAL: Gitea Webhook Allowed Hosts
Gitea blocks webhooks to internal hosts by default! Before setting up webhooks, configure ALLOWED_HOST_LIST in Gitea's app.ini:
# Edit Gitea config
ssh nuc "docker exec gitea-ho0cwgcwos88cwc48g84c0g8 vi /data/gitea/conf/app.ini"
# Add/modify [webhook] section:
[webhook]
ALLOWED_HOST_LIST = coolify,10.0.1.5,192.168.1.3,localhost,host.docker.internal,external
# Restart Gitea
ssh nuc "docker restart gitea-ho0cwgcwos88cwc48g84c0g8"
Without this, webhooks fail with: webhook can only call allowed HTTP servers
Key References
| Resource | Value |
|---|---|
| Deploy Key UUID | akssgwowsccgwgoggs4ks8ck |
| Gitea Container | gitea-ho0cwgcwos88cwc48g84c0g8 |
| Webhook Secret | 9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718 |
⚠️ CRITICAL: Webhook Secret Must Be Set in BOTH Places
Auto-deploy won't work unless the webhook secret is configured in BOTH Coolify AND Gitea!
Step 1: Set secret in Coolify (via tinker or when creating app):
ssh nuc "docker exec coolify php artisan tinker --execute=\"
use App\\Models\\Application;
\\\$app = Application::where('uuid', '<app-uuid>')->first();
\\\$app->manual_webhook_secret_gitea = '9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718';
\\\$app->save();
\""
Step 2: Set secret in Gitea webhook:
- Go to repo → Settings → Webhooks → Add/Edit Webhook
- Target URL:
http://coolify:8080/webhooks/source/gitea/events/manual?uuid=<app-uuid> - Secret:
9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718 - Trigger: Push Events
- Active: ✓
Common symptom when secret is missing: Git pushes succeed but no deployment is triggered. Check:
# Verify Coolify has the secret
ssh nuc "docker exec coolify php artisan tinker --execute=\"
use App\\Models\\Application;
\\\$app = Application::where('uuid', '<app-uuid>')->first();
echo 'Secret: ' . (\\\$app->manual_webhook_secret_gitea ? 'SET' : 'MISSING');
\""
Local Development (Clone & Push)
SSH config is set up for direct git operations:
# Clone a repo
git clone gitea:alezmad/nuc-portal.git
# Push changes (triggers auto-deploy via webhook)
git push origin main
SSH Host Config (in ~/.ssh/config):
Host gitea
HostName 192.168.1.3
Port 22222
User git
IdentityFile ~/.ssh/id_ed25519_nuc
Webhook URL Format (MUST include UUID and use port 8080!)
http://coolify:8080/webhooks/source/gitea/events/manual?uuid=<app-uuid>
⚠️ Port 8080 is required - Coolify listens on 8080 internally (not 8000, which is the external mapping).
| App | UUID | Webhook URL |
|---|---|---|
| nuc-portal | t80w0cw0oooc4g0soswos4so |
...?uuid=t80w0cw0oooc4g0soswos4so |
| whyrating-hub | vw4ggc40socwkgwg4osc8wg8 |
...?uuid=vw4ggc40socwkgwg4osc8wg8 |
| whyrating-brand | r80gk0ccgg0okos8cw848kkk |
...?uuid=r80gk0ccgg0okos8cw848kkk |
| whyrating-templates | qw80g4sog0kk8cc4wkcs8sgc |
...?uuid=qw80g4sog0kk8cc4wkcs8sgc |
Quick Deploy (Next.js)
# 1. Create application with deploy key
mcp__coolify__application(
action="create_key",
name="my-app",
project_uuid="a8484ggc88c40w4g4k004ow0",
environment_name="production",
server_uuid="qk84w0goo4w48g4ggsoo0oss",
git_repository="git@gitea-ho0cwgcwos88cwc48g84c0g8:alezmad/<repo>.git",
git_branch="main",
build_pack="nixpacks",
ports_exposes="3000",
private_key_uuid="akssgwowsccgwgoggs4ks8ck"
)
# 2. Set FQDN
ssh nuc "docker exec coolify php artisan tinker --execute=\"
use App\Models\Application;
\\\$app = Application::where('uuid', '<app-uuid>')->first();
\\\$app->fqdn = 'http://<name>.nuc.lan';
\\\$app->custom_labels = null;
\\\$app->save();
\""
# 3. Set webhook secret
ssh nuc "docker exec coolify php artisan tinker --execute=\"
use App\Models\Application;
\\\$app = Application::where('uuid', '<app-uuid>')->first();
\\\$app->manual_webhook_secret_gitea = '9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718';
\\\$app->save();
\""
# 4. Create webhook in Gitea (via browser or API):
# URL: http://coolify:8080/webhooks/source/gitea/events/manual?uuid=<app-uuid>
# (Use port 8080, NOT 8000 - 8080 is the internal container port)
# Secret: 9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718
# Trigger: Push Events
# 5. Deploy
mcp__coolify__deploy(tag_or_uuid="<app-uuid>")
Deploy Key (add to each new repo)
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGHtsL3jicJTsBekYuwbKjO0EcRadYKhvLSUw/36XF7h coolify-gitea
Add via Gitea: Repository → Settings → Deploy Keys → Enable Write Access ✓
✅ New Repo Auto-Deploy Checklist
When creating a new repo that should auto-deploy:
-
[ ] Add deploy key to Gitea repo
- Go to:
http://192.168.1.3:3030/alezmad/<repo>/settings/keys - Add the deploy key above with Write Access enabled
- Go to:
-
[ ] Create Coolify application (use
mcp__coolify__applicationwithaction="create_key") -
[ ] Set FQDN via tinker command
-
[ ] Set webhook secret via tinker command (use shared secret above)
-
[ ] Create Gitea webhook
- Go to:
http://192.168.1.3:3030/alezmad/<repo>/settings/hooks - Add Webhook → Gitea
- URL:
http://coolify:8080/webhooks/source/gitea/events/manual?uuid=<app-uuid> - Secret:
9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718 - Trigger: Push Events
- Active: ✓
- Go to:
-
[ ] Test webhook - Click "Test Delivery" and verify HTTP 200 response
-
[ ] Initial deploy -
mcp__coolify__deploy(tag_or_uuid="<app-uuid>")
Common mistakes:
- ❌ Using port 8000 instead of 8080 in webhook URL
- ❌ Forgetting
?uuid=<app-uuid>in webhook URL - ❌ Not enabling Write Access on deploy key
Quick Verification Commands
# Check Gitea has [webhook] section configured
ssh nuc "docker exec gitea-ho0cwgcwos88cwc48g84c0g8 cat /data/gitea/conf/app.ini | grep -A2 '\[webhook\]'"
# Test Coolify is reachable from Gitea (should return HTML)
ssh nuc "docker exec gitea-ho0cwgcwos88cwc48g84c0g8 wget -qO- --timeout=5 http://coolify:8080/ | head -5"
# Check Gitea is on coolify network
ssh nuc "docker inspect gitea-ho0cwgcwos88cwc48g84c0g8 --format '{{json .NetworkSettings.Networks}}' | jq -r 'keys[]'"
Why NOT "Gitea Source"
Coolify's "Gitea Source" uses GitHub App-style OAuth with JWT - this doesn't work with Gitea. Use deploy keys + manual webhooks instead.
Current Deployed Apps
| App | URL | Repository | UUID |
|---|---|---|---|
| nuc-portal | http://nuc.lan | alezmad/nuc-portal |
t80w0cw0oooc4g0soswos4so |
| whyrating-hub | http://whyrating.nuc.lan | alezmad/whyrating-hub |
vw4ggc40socwkgwg4osc8wg8 |
| whyrating-brand | http://brand.nuc.lan | alezmad/whyrating-brand |
r80gk0ccgg0okos8cw848kkk |
| whyrating-templates | http://templates.nuc.lan | alezmad/whyrating-templates |
qw80g4sog0kk8cc4wkcs8sgc |
| turbostarter | https://alezmad-nuc.tail58f5ad.ts.net | alezmad/turbostarter |
v4gogwwc8wkk4888ksscc4k4 (service) |
| arrio | http://arrio.nuc.lan | alezmad/arrio |
tgksg0s8gocko4csggs0808c (service) |
Turbostarter (Knosia) - Build & Deploy
Turbostarter is deployed as a Coolify Service (not a standalone app) with full docker-compose infrastructure: web + pgvector + minio.
Architecture: Tailscale Funnel (HTTPS) → Traefik (HTTP:80) → web container
FQDN (Traefik): http://alezmad-nuc.tail58f5ad.ts.net (HTTP internally — Tailscale handles TLS termination)
Build & Deploy workflow:
# 1. Build image locally (ARM→AMD cross-compile)
cd /Users/agutierrez/Desktop/turbostarter-export
docker build --platform linux/amd64 \
--build-arg NEXT_PUBLIC_URL=https://alezmad-nuc.tail58f5ad.ts.net \
-t 192.168.1.3:3030/alezmad/turbostarter:latest .
# 2. Push to Gitea registry
docker push 192.168.1.3:3030/alezmad/turbostarter:latest
# 3. Redeploy via Coolify (stop + start for full container recreation)
mcp__coolify__control(resource="service", action="stop", uuid="v4gogwwc8wkk4888ksscc4k4")
mcp__coolify__control(resource="service", action="start", uuid="v4gogwwc8wkk4888ksscc4k4")
Containers:
| Container | Image | Purpose |
|---|---|---|
web-v4gogwwc8wkk4888ksscc4k4 |
localhost:3030/alezmad/turbostarter:latest |
Next.js app |
db-v4gogwwc8wkk4888ksscc4k4 |
pgvector/pgvector:pg17 |
PostgreSQL + pgvector |
minio-v4gogwwc8wkk4888ksscc4k4 |
minio/minio:latest |
Object storage |
minio-init-v4gogwwc8wkk4888ksscc4k4 |
minio/mc:latest |
One-time bucket init |
Database access (via SSH tunnel):
# Get DB container IP first
ssh nuc "docker inspect db-v4gogwwc8wkk4888ksscc4k4 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'"
# Then tunnel to IP (not container name)
ssh -L 5440:<container_ip>:5432 nuc
# Connect: postgres://turbostarter:turbostarter@localhost:5440/core
Seeded users: me+admin@turbostarter.dev / Pa$$w0rd (admin), me+user@turbostarter.dev / Pa$$w0rd
Key env vars:
BETTER_AUTH_TRUSTED_ORIGINS— comma-separated list of allowed origins (CSRF protection)NEXT_PUBLIC_URL— build-time arg baked into Next.js static output (must rebuild to change)DATABASE_URL— internal docker network connection to pgvector
New Site from nuc-portal Template
To create a new Next.js site using nuc-portal as a base:
# 1. Copy and clean
cp -r /path/to/nuc-portal /path/to/new-site
cd /path/to/new-site
rm -rf .git .next node_modules
# 2. Update package.json (name, description)
# 3. Customize src/app/page.tsx
# 4. Remove unused components if simplifying
# 5. Initialize and push
npm install && npm run build # verify it builds
git init && git add -A && git commit -m "Initial commit"
# Create repo in Gitea first, then:
git remote add origin gitea:alezmad/<repo-name>.git
git push -u origin main
Then follow the "New Repo Auto-Deploy Checklist" above.
Public Access & Security Architecture
Full architecture details: docs/architecture.md
Why Tailscale Funnel (Not Cloudflare)
Cloudflare shared IPs get blocked by Spanish ISPs during LaLiga matches. Tailscale Funnel:
- Uses different IP infrastructure (not blocked)
- Handles dynamic ISP IP changes automatically
- No ports exposed on router
- HTTPS termination included
Tailscale Funnel (Public Access)
| Property | Value |
|---|---|
| Funnel URL | https://nuc-tailscale.tail58f5ad.ts.net |
| Tailscale IP | 100.x.x.x (stable, never changes) |
| Status | ssh nuc "tailscale funnel status" |
⚠️ CRITICAL: Funnel only supports ports 443, 8443, and 10000. Other ports will appear to work locally (tailscale funnel status shows them) but traffic will NOT route from the public internet. Always use one of these three ports.
⚠️ CRITICAL: Always use 127.0.0.1, NOT localhost in Funnel/Serve targets. On the NUC, localhost resolves to IPv6 (::1) but Docker containers bind to IPv4 only, causing connection resets.
Start Funnel for a service:
# Expose port 3000 via Funnel (use 127.0.0.1, NOT localhost!)
ssh nuc "printf '<sudo_pass>\n' | sudo -S tailscale funnel --bg --https=8443 http://127.0.0.1:3000"
Current Funnel allocation:
| Port | Service | Target |
|---|---|---|
| 443 | Traefik (main) | http://192.168.1.3:80 |
| 8443 | Palmr (file sharing) | http://127.0.0.1:3334 |
| 10000 | Palmr MinIO (uploads) | http://127.0.0.1:9379 |
Current Domain Routes
| Domain | Destination | Method |
|---|---|---|
| whyrating.com | nuc-tailscale.tail58f5ad.ts.net |
Namecheap 301 redirect |
Adding a New Domain
-
Check availability (if needed):
mcp__namecheap__namecheap_check_domain_availability(domains=["example.com"]) -
Configure redirect at registrar (Namecheap):
- Go to Domain List → Manage → Redirect Domain
- Source:
example.com - Destination:
https://nuc-tailscale.tail58f5ad.ts.net - Type: Permanent (301)
-
Start Funnel on NUC for the target port
Security Layers
Internet → Tailscale Funnel (HTTPS) → CrowdSec → Traefik → Container
↓
Blocks malicious IPs
| Layer | Purpose |
|---|---|
| Tailscale Funnel | Only entry point, HTTPS termination |
| CrowdSec | DDoS protection, threat intelligence |
| Traefik | Domain routing, rate limiting |
| Docker Networks | Container isolation |
Tailscale Mesh (Admin Access)
Private encrypted access from anywhere:
| From | Command |
|---|---|
| Remote SSH | ssh nuc-tailscale |
| Remote Coolify | http://nuc-tailscale:8000 |
| Home network | ssh nuc or http://192.168.1.3:8000 |
NOT exposed to internet: SSH (22), Coolify (8000), databases, MinIO, Authentik, router admin.
Dynamic IP Handling
ISP can change your public IP anytime. Tailscale handles this automatically:
- Tailscale IP (100.x.x.x) stays stable
- Funnel URL stays stable
- Domain redirects stay stable
- Tunnels auto-reconnect in ~10-30 seconds
Artifacts Folder
Location: .artifacts/
This folder stores important information artifacts that should be preserved for future reference. Claude should proactively save artifacts here when relevant information is generated during sessions.
Naming Convention
Files must use datetime-prefixed names:
YYYY-MM-DD_HH-MM_<description>.md
Examples:
2026-02-01_13-45_coolify-api-token.md2026-02-01_14-30_n8n-mcp-setup.md2026-02-01_15-00_service-health-report.md
When to Save Artifacts
Always save artifacts for:
- API tokens, keys, or credentials generated during sessions
- Configuration changes made to services
- Troubleshooting steps that resolved issues
- Infrastructure changes or deployments
- MCP server configurations and setup details
- Database schema changes or migrations
- Important command outputs that may be needed later
- Service health reports or diagnostics
Artifact Format
# <Title>
**Date:** YYYY-MM-DD HH:MM
**Context:** <Brief description of what was being done>
## Details
<Relevant information, configs, tokens, commands, etc.>
## Related
- <Links to services, docs, or other artifacts>
Publishing JSX/React Artifacts Online
Single-file React components (JSX) can be published as standalone web pages via the NUC's artifacts infrastructure.
How It Works
Public Internet → Tailscale Funnel (:443) → Traefik → artifacts-web (nginx) → /opt/artifacts/
- Funnel URL:
https://alezmad-nuc.tail58f5ad.ts.net/artifacts/<name>/ - LAN URL:
https://artifacts.nuc.lan/<name>/ - Nginx container:
artifacts-web(image:nginx:alpine, read-only mount of/opt/artifacts) - Traefik public route:
Host(alezmad-nuc.tail58f5ad.ts.net) && PathPrefix(/artifacts)→ strips/artifacts→artifacts-web:80 - Config file:
/traefik/dynamic/nuc-services-public.yaml(insidecoolify-proxycontainer)
Quick Publish Steps
# 1. Build self-contained HTML from JSX
# - Replace `import { useState, ... } from "react"` with `const { useState, ... } = React;`
# - Remove `export default ComponentName;`
# - Wrap in HTML with React 18 CDN + Babel standalone
# - Add `ReactDOM.createRoot(root).render(<Component />)` at the end
# 2. Copy to NUC artifacts directory
ssh nuc "echo '7vXHpSTD.' | sudo -S mkdir -p /opt/artifacts/<name>"
scp /tmp/build/index.html nuc:~/tmp-artifact.html
ssh nuc "echo '7vXHpSTD.' | sudo -S mv ~/tmp-artifact.html /opt/artifacts/<name>/index.html"
ssh nuc "echo '7vXHpSTD.' | sudo -S chmod 644 /opt/artifacts/<name>/index.html"
# 3. Done! No server restart needed — nginx serves it immediately.
HTML Template for JSX Files
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TITLE</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #08090d; overflow-x: hidden; }
/* Add any @keyframes or global styles here */
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback } = React;
// ... paste JSX component code here (without import/export lines) ...
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(ComponentName));
</script>
</body>
</html>
Build Script (for large JSX files)
# Automated: strips import/export, wraps in HTML
cat > /tmp/build.html << 'HEADER'
<!DOCTYPE html>
<html><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head><body><div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback, useMemo, useReducer } = React;
HEADER
# Strip first line (import) and last line (export), append body
sed -n '2,$p' source.jsx | sed '$d' >> /tmp/build.html
# Add render footer (replace COMPONENT_NAME)
cat >> /tmp/build.html << 'FOOTER'
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(COMPONENT_NAME));
</script></body></html>
FOOTER
Currently Published
| Path | Source | Public URL |
|---|---|---|
/opt/artifacts/checkin/ |
arrio/.scratch/checkin_demo_v1.jsx |
https://alezmad-nuc.tail58f5ad.ts.net/artifacts/checkin/ |
OpenWrt Interaction Methods (Quick Reference)
| Method | When to Use | Example |
|---|---|---|
| SSH | Direct commands, config changes, package management | ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 "uci show network" |
| OpenWrt MCP | AI-driven automation, status queries | curl -H "x-api-token: openwrt-mcp-secret-2026" http://192.168.1.1:8090/status |
| Chrome DevTools MCP | LuCI UI automation when no CLI/API exists | mcp__chrome-devtools__navigate_page(url="http://192.168.1.1/...") |
| LuCI Web UI | Manual configuration, visual inspection | http://192.168.1.1 (user: root) |
Priority Order: SSH > OpenWrt MCP > Chrome DevTools > Manual UI
Palmr (File Sharing - Dropbox Alternative)
Self-hosted file sharing platform (like WeTransfer/Dropbox) for sending and receiving files.
Access
| Property | Value |
|---|---|
| Public URL | https://alezmad-nuc.tail58f5ad.ts.net:8443 |
| Local URL | http://192.168.1.3:3334 |
| Login page | /login |
| Container | palmr |
| Image | kyantech/palmr:latest |
| Network | coolify (IP: 10.0.1.5) |
| Sudo pass | 7vXHpSTD. |
Users
| Username | Password | Admin | |
|---|---|---|---|
alezmad |
agutierrez@mineryreport.com |
(set via UI) | Yes |
michi |
michi@nuc.lan |
FlexiCar2025. |
No |
Architecture
External Browser → Funnel :8443 → 127.0.0.1:3334 → container:5487 (Next.js frontend)
Funnel :10000 → 127.0.0.1:9379 → container:9379 (MinIO uploads)
↑ via port-fwd-palmr-minio
Palmr uses presigned URLs for file uploads — the browser uploads directly to MinIO, NOT through the backend. This means MinIO MUST be reachable from the client's browser.
Key Environment Variables
| Variable | Value | Purpose |
|---|---|---|
STORAGE_URL |
https://alezmad-nuc.tail58f5ad.ts.net:10000 |
Public MinIO URL for presigned upload URLs |
API_BASE_URL |
http://127.0.0.1:3333 |
Internal backend API (Next.js → Fastify) |
Volume Mounts
| Volume | Mount Point | Contents |
|---|---|---|
5273abd0c... (anonymous) |
/app/server |
SQLite DB, MinIO data, credentials |
palmr_uploads |
/app/uploads |
Uploaded files |
Container Recreation
ssh nuc "docker stop palmr && docker rm palmr && docker run -d \
--name palmr \
--network coolify \
--restart unless-stopped \
-p 3334:5487 \
-v 5273abd0c536116056362397cdb568d2eab066b8289412dd91ecce58c174df68:/app/server \
-v palmr_uploads:/app/uploads \
-e STORAGE_URL=https://alezmad-nuc.tail58f5ad.ts.net:10000 \
kyantech/palmr:latest"
Port Forwarder (MinIO)
MinIO runs inside the Palmr container on port 9379 (not exposed). A socat forwarder bridges it:
ssh nuc "docker run -d --name port-fwd-palmr-minio --network coolify -p 9379:9379 \
alpine/socat tcp-listen:9379,fork,reuseaddr tcp-connect:palmr:9379"
Database Access
Palmr uses SQLite at /app/server/prisma/palmr.db. Passwords are bcrypt hashed.
# Copy DB locally for queries
ssh nuc "docker cp palmr:/app/server/prisma/palmr.db /tmp/palmr.db"
scp nuc:/tmp/palmr.db /tmp/palmr.db
sqlite3 /tmp/palmr.db "SELECT username, email, isAdmin FROM users;"
# Generate bcrypt hash inside container
ssh nuc "docker exec palmr sh -c 'cd /app/palmr-app && node -e \"const bcrypt = require(\\\"bcryptjs\\\"); console.log(bcrypt.hashSync(\\\"PASSWORD\\\", 10));\"'"
Troubleshooting
-
Upload stuck on loader: Check
docker logs palmrforSTORAGE_URLerrors. MinIO must be reachable from the client browser via the presigned URL. -
Blank page externally: Verify Funnel is on a supported port (443/8443/10000) and target uses
127.0.0.1notlocalhost. -
JWT token invalid after restart: Expected — users must log in again after container recreation (JWT secret regenerated).
-
Config limits: File size and storage limits are in
app_configstable (values in bytes). Update via SQLite, copy DB back, restart.
MinIO Object Storage (Mac ↔ NUC File Transfer)
Architecture
MinIO runs on the NUC as S3-compatible object storage. Used for Mac backups, file transfers, and app storage.
Mac (mc CLI) → Tailscale → 100.113.153.45:9000 → port-fwd (socat) → minio-xwowg8kswwsocssgocs8ss40:9000
Access
| Property | Value |
|---|---|
| API URL | http://100.113.153.45:9000 |
| Console URL | http://100.113.153.45:9001 |
| Access Key | minioadmin |
| Secret Key | minioadmin |
| Port Forwarder | minio-port-fwd (socat, maps host :9000/:9001 → container) |
| Main Container | minio-xwowg8kswwsocssgocs8ss40 |
Buckets
| Bucket | Purpose |
|---|---|
mac-backups |
Database dumps, tar archives, important backups |
mac-downloads |
Files moved from ~/Downloads for archival |
mac-projects |
Archived project files |
nuc-portal-previews |
NUC Portal screenshot previews |
whyrating |
WhyRating app assets |
Mac CLI (mc)
Installed at /opt/homebrew/bin/mc, alias nuc pre-configured.
# List buckets
mc ls nuc/
# Upload file
mc cp file.tar nuc/mac-backups/
# Download file
mc cp nuc/mac-backups/file.tar ./
# Sync a directory
mc mirror ~/path/to/dir nuc/mac-projects/dirname/
# Check bucket size
mc du nuc/mac-backups
nuc-sync CLI Tool
Wrapper script at ~/.local/bin/nuc-sync for daily operations:
nuc-sync status # Check NUC + MinIO connectivity
nuc-sync upload file.tar # Upload to mac-backups bucket
nuc-sync upload file.sql mac-downloads # Upload to specific bucket
nuc-sync push ~/Desktop/project # rsync to NUC (excludes node_modules, .next, .git)
nuc-sync pull /opt/backups/mac/x . # rsync from NUC
nuc-sync list # List all buckets
nuc-sync list mac-backups # List bucket contents
MinIO MCP Server
MCP server for Claude Code integration: minio-nuc
- Source:
~/mcp-servers/minio-mcp-server/ - GitHub:
https://github.com/Rafitis/minio-mcp-server - Scope: user (available in all projects)
- Tools: list buckets, upload/download objects, create/delete buckets
rsync Backup Directory
# NUC backup path (owned by alezmad)
/opt/backups/mac/
Troubleshooting
-
MinIO unreachable (connection refused): The port forwarder may be down:
ssh nuc "docker start minio-port-fwd" -
mc alias not working: Reconfigure:
/opt/homebrew/bin/mc alias set nuc http://100.113.153.45:9000 minioadmin minioadmin -
Console not loading: Port 9001 needs the port-fwd container running. Check with:
ssh nuc "docker ps | grep minio-port-fwd"
Next Steps / Migration Candidates
Priority 1: Safe to Delete (Duplicates/Old Versions)
| Image | Size | Action | Reason |
|---|---|---|---|
google-reviews-scraper-pro-api (old) |
3.62GB | ❌ DELETE | Old version, newer exists |
claudefarm-claudefarm |
3.87GB | ❌ DELETE | Replaced by claudefarm-browser + claudefarm-api |
postgres:16 |
657MB | ❌ DELETE | Using 16-alpine (389MB) instead |
prom/mysqld-exporter:v0.14.0 |
28MB | ❌ DELETE | 3 years old, likely unused |
Savings: ~8.2GB
Priority 2: Migrate to NUC (High Value)
| Image | Size | Priority | Notes |
|---|---|---|---|
nocodb/nocodb |
1.24GB | ⭐ HIGH | Airtable alternative - great for self-hosted data |
grafana/grafana |
932MB | ⭐ HIGH | Pairs with existing Uptime Kuma for monitoring |
prom/prometheus |
479MB | ⭐ HIGH | Metrics backend for Grafana |
timescale/timescaledb |
1.45GB | ⭐ HIGH | Time-series data, useful for IoT/metrics |
Priority 3: Migrate to NUC (Medium Value)
| Image | Size | Priority | Notes |
|---|---|---|---|
mysql:8 |
1.07GB | 🔶 MEDIUM | Only if you have MySQL-specific apps |
minio/minio + minio/mc |
340MB | 🔶 SKIP | Already running on NUC via Coolify |
Priority 4: MCP Tools - Evaluate Usage
| Image | Size | Recommendation | Notes |
|---|---|---|---|
mcp/n8n |
675MB | 🔶 SKIP | n8n already on NUC; this is just MCP wrapper |
mcp/youtube-transcript |
321MB | ✅ KEEP LOCAL | Useful for AI workflows |
mcp/context7 |
423MB | ✅ KEEP LOCAL | Documentation lookup, AI essential |
mcp/fetch |
? | ✅ KEEP LOCAL | Web fetching for AI |
Priority 5: Review Before Deleting
| Image | Size | Action | Why Review |
|---|---|---|---|
mysql:8 |
1.07GB | ⚠️ CHECK | May have local databases; verify before delete |
timescale/timescaledb |
1.45GB | ⚠️ CHECK | May have local time-series data |
Recommended Coolify Deployments
# 1. NocoDB (Airtable alternative)
mcp__coolify__service(action="create", type="nocodb", name="NocoDB",
server_uuid="qk84w0goo4w48g4ggsoo0oss", project_uuid="a8484ggc88c40w4g4k004ow0",
environment_name="production", instant_deploy=True)
# 2. Prometheus + Grafana stack
mcp__coolify__service(action="create", type="grafana", ...)
mcp__coolify__service(action="create", type="prometheus", ...)
Migration Checklist
- Delete old/duplicate images locally
- Deploy NocoDB to NUC
- Deploy Grafana + Prometheus monitoring stack
- Consider TimescaleDB if IoT/metrics needed
- Verify MySQL data before deleting
- Add CloudBeaver to Uptime Kuma monitoring
- Configure OpenWrt MCP MQTT broker (optional)
OpenClaw (AI Assistant Gateway)
Self-hosted AI assistant gateway running on the NUC via Docker Compose. Connects to messaging platforms (WhatsApp, Telegram, Discord, etc.) and routes messages through Claude.
Access
| Property | Value |
|---|---|
| Control UI (HTTPS) | https://alezmad-nuc.tail58f5ad.ts.net:8443 |
| Gateway WS | ws://192.168.1.3:18789 |
| Gateway Token | 3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee |
| Model | anthropic/claude-sonnet-4-5-20250929 |
| Repo on NUC | ~/openclaw/ |
| Config | ~/.openclaw/openclaw.json |
| Version | 2026.2.10 |
⚠️ HTTPS Required: The Control UI requires a secure context (HTTPS or localhost). Access via Tailscale Serve on port 8443.
Tailscale Serve (HTTPS access)
The gateway is exposed via Tailscale Serve (not Funnel - tailnet only, not public):
# Start HTTPS proxy (requires sudo, must run from NUC terminal)
ssh nuc
sudo tailscale serve --bg --https=8443 http://localhost:18789
# Password: 7vXHpSTD
⚠️ The --bg flag makes it persistent. Without it, Ctrl+C stops the proxy.
Docker Compose Management
# Start gateway
ssh nuc "cd ~/openclaw && docker compose up -d openclaw-gateway"
# Restart gateway
ssh nuc "cd ~/openclaw && docker compose restart openclaw-gateway"
# View logs
ssh nuc "docker logs openclaw-openclaw-gateway-1 2>&1 | tail -30"
# Run CLI commands (use docker exec for commands that need gateway connection)
ssh nuc "docker exec openclaw-openclaw-gateway-1 node dist/index.js <command> --token 3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee --url ws://127.0.0.1:18789"
# Run CLI commands that don't need gateway (use docker compose run)
ssh nuc "cd ~/openclaw && script -qc 'docker compose run --rm openclaw-cli <command>' /dev/null"
Device Pairing
When the Control UI shows "pairing required", approve the pending device:
# List pending devices
ssh nuc "docker exec openclaw-openclaw-gateway-1 node dist/index.js devices list --token 3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee --url ws://127.0.0.1:18789"
# Approve a device (use requestId from the list)
ssh nuc "docker exec openclaw-openclaw-gateway-1 node dist/index.js devices approve <requestId> --token 3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee --url ws://127.0.0.1:18789"
Dashboard URL with embedded token (auto-authenticates):
https://alezmad-nuc.tail58f5ad.ts.net:8443/#token=3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee
Channel Plugins
Channels are plugins that must be enabled before use. 35 available, 4 loaded by default.
# List all plugins
ssh nuc "script -qc 'cd ~/openclaw && docker compose run --rm openclaw-cli plugins list' /dev/null"
# Enable a channel plugin
ssh nuc "script -qc 'cd ~/openclaw && docker compose run --rm openclaw-cli plugins enable <plugin-id>' /dev/null"
# Then restart gateway
ssh nuc "cd ~/openclaw && docker compose restart openclaw-gateway"
Currently enabled channels: WhatsApp (linked and active)
Available channel plugins:
| Plugin ID | Channel |
|---|---|
whatsapp |
|
telegram |
Telegram |
discord |
Discord |
slack |
Slack |
signal |
Signal |
matrix |
Matrix |
msteams |
Microsoft Teams |
googlechat |
Google Chat |
imessage |
iMessage |
irc |
IRC |
Setting Up Channels That Require QR Codes (WhatsApp, etc.)
⚠️ The CLI needs a TTY for QR display. Cannot run directly via ssh nuc "command". Use script to fake a TTY and capture output:
# Step 1: Enable the plugin
ssh nuc "script -qc 'cd ~/openclaw && docker compose run --rm openclaw-cli plugins enable whatsapp' /dev/null"
# Step 2: Restart gateway
ssh nuc "cd ~/openclaw && docker compose restart openclaw-gateway"
# Step 3: Run login with TTY capture (captures QR to file)
ssh nuc "script -q /tmp/openclaw-qr.txt -c 'cd ~/openclaw && docker compose run --rm openclaw-cli channels login'"
# Step 4: If QR needs to be viewed remotely, copy and render as image
scp nuc:/tmp/openclaw-qr.txt /tmp/
# Then use Python + Pillow to convert Unicode block chars to PNG
QR to PNG conversion (run locally on Mac):
from PIL import Image
import re
with open("/tmp/openclaw-qr.txt", "rb") as f:
content = f.read().decode("utf-8", errors="replace")
lines = content.split("\n")
qr_lines = []
for l in lines:
clean = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", l).replace(chr(0), "")
if any(c in clean for c in ["\u2584", "\u2588", "\u2580"]):
qr_lines.append(clean)
scale = 10
width = max(len(l) for l in qr_lines)
height = len(qr_lines) * 2
img = Image.new("RGB", (width * scale, height * scale), "white")
for row_idx, line in enumerate(qr_lines):
for col_idx, ch in enumerate(line):
top_black = ch in ["\u2588", "\u2580"]
bot_black = ch in ["\u2588", "\u2584"]
for dy in range(scale):
for dx in range(scale):
if top_black:
img.putpixel((col_idx*scale+dx, row_idx*2*scale+dy), (0,0,0))
if bot_black:
img.putpixel((col_idx*scale+dx, (row_idx*2+1)*scale+dy), (0,0,0))
img.save("/tmp/qr.png")
Alternative: User scans directly — If the user has SSH terminal access, they can run channels login interactively and scan the QR from their terminal.
Anthropic Authentication
Uses a Claude Code OAuth token (valid 1 year). Set in both config and docker-compose env:
Config (~/.openclaw/openclaw.json):
{
"env": {
"ANTHROPIC_API_KEY": "sk-ant-oat01-..."
}
}
Docker env (~/openclaw/.env):
ANTHROPIC_API_KEY=sk-ant-oat01-...
To regenerate token (run on Mac where claude CLI is installed):
claude setup-token
# Copy the output token and update both config + .env on NUC
Config Schema (current)
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-sonnet-4-5-20250929"
}
}
},
"gateway": {
"port": 18789,
"mode": "local",
"bind": "lan",
"auth": {
"mode": "token"
}
}
}
⚠️ Config gotchas:
agent.modelis legacy — useagents.defaults.model.primaryinstead- Run
docker exec openclaw-openclaw-gateway-1 node dist/index.js doctor --fixto migrate legacy keys - The
gateway.pairingkey does NOT exist — device pairing is managed via thedevicesCLI, not config
Troubleshooting
-
"control ui requires HTTPS or localhost": Access via
https://alezmad-nuc.tail58f5ad.ts.net:8443(Tailscale Serve), NOThttp://192.168.1.3:18789 -
"pairing required": Approve the device via
devices approvecommand (see Device Pairing section above) -
"unauthorized: gateway token missing": Use the dashboard URL with
#token=...hash to auto-authenticate -
CLI commands fail with "gateway closed": Use
docker execinto the running gateway container instead ofdocker compose run(the CLI container can't reach the gateway on its internal Docker IP) -
Config "invalid" after edit: Run
doctor --fixinside the gateway container to clean up -
Channel "unsupported": Enable the plugin first with
plugins enable <id>, then restart gateway -
CLAUDE_AI_SESSION_KEY warnings: Harmless — these are for Claude web session auth which isn't used when using API key