Last Updated: January 8, 2026 at 05:30 UTC

Database Schemas

PostgreSQL 16 - Main Application Database

Database Name: madmonkey

Host: 10.0.0.250:5432 (bennybeen)

User: mm_user

Connection String: postgresql://mm_user:PASSWORD@10.0.0.250:5432/madmonkey

Table: cards

QR card inventory and tracking

CREATE TABLE cards (
    qr_id VARCHAR(10) PRIMARY KEY,              -- e.g., 'N042' or 'T007'
    type VARCHAR(10) NOT NULL,                  -- 'nest' or 'traveler'
    status VARCHAR(20) DEFAULT 'active',        -- 'active', 'lost', 'retired'
    current_slug VARCHAR(50),                   -- Current Shlink short URL slug
    move_code VARCHAR(20) UNIQUE,               -- Unique code for traveler cards
    last_scan_ts TIMESTAMP,                     -- Last scan timestamp
    last_scan_city VARCHAR(100),                -- Last known city
    total_scans INTEGER DEFAULT 0,              -- Total scan count
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_cards_type ON cards(type);
CREATE INDEX idx_cards_status ON cards(status);
CREATE INDEX idx_cards_last_scan_ts ON cards(last_scan_ts);

Table: scans

Individual scan event logs

CREATE TABLE scans (
    scan_id SERIAL PRIMARY KEY,
    qr_id VARCHAR(10) NOT NULL REFERENCES cards(qr_id),
    ts TIMESTAMP DEFAULT NOW(),                 -- Scan timestamp
    ip_anon VARCHAR(45),                        -- Anonymized IP (last octet removed)
    user_agent TEXT,                            -- Browser user agent
    geo_city VARCHAR(100),                      -- City from IP geolocation
    geo_region VARCHAR(100),                    -- State/province
    geo_country VARCHAR(2),                     -- ISO country code
    geo_lat DECIMAL(10, 7),                     -- Latitude
    geo_lon DECIMAL(10, 7),                     -- Longitude
    referer TEXT,                               -- HTTP referer if present
    device_type VARCHAR(20)                     -- 'mobile', 'desktop', 'tablet'
);

CREATE INDEX idx_scans_qr_id ON scans(qr_id);
CREATE INDEX idx_scans_ts ON scans(ts);
CREATE INDEX idx_scans_geo_city ON scans(geo_city);

Table: entries

Contest entry records

CREATE TABLE entries (
    entry_id SERIAL PRIMARY KEY,
    qr_id VARCHAR(10) NOT NULL REFERENCES cards(qr_id),
    email VARCHAR(255) NOT NULL,                -- Subscriber email
    name VARCHAR(100),                          -- Subscriber name
    user_city VARCHAR(100),                     -- User-reported "where found"
    verified BOOLEAN DEFAULT FALSE,             -- Double opt-in confirmed
    month_bucket VARCHAR(7),                    -- 'YYYY-MM' for monthly drawing
    created_at TIMESTAMP DEFAULT NOW(),
    verified_at TIMESTAMP,
    listmonk_subscriber_id INTEGER,             -- Reference to Listmonk
    UNIQUE(email, month_bucket)                 -- One entry per email per month
);

CREATE INDEX idx_entries_email ON entries(email);
CREATE INDEX idx_entries_verified ON entries(verified);
CREATE INDEX idx_entries_month_bucket ON entries(month_bucket);

Table: moves

Traveler card movement tracking

CREATE TABLE moves (
    move_id SERIAL PRIMARY KEY,
    traveler_id VARCHAR(10) NOT NULL REFERENCES cards(qr_id),
    rev INTEGER NOT NULL,                       -- Movement revision number
    mover_email VARCHAR(255),                   -- Who moved the card
    pickup_city VARCHAR(100),                   -- Where card was picked up
    drop_city VARCHAR(100),                     -- Where card was dropped off
    pickup_ts TIMESTAMP,
    drop_ts TIMESTAMP,
    distance_km DECIMAL(10, 2),                 -- Calculated distance
    notes TEXT,                                 -- User notes about the move
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_moves_traveler_id ON moves(traveler_id);
CREATE INDEX idx_moves_rev ON moves(rev);

Table: nests

Venue/location tracking for nest cards

CREATE TABLE nests (
    venue_id SERIAL PRIMARY KEY,
    name VARCHAR(200) NOT NULL,                 -- Venue name
    address TEXT,                               -- Street address
    city VARCHAR(100),
    state VARCHAR(50),
    zip VARCHAR(20),
    contact_name VARCHAR(100),                  -- Venue contact person
    contact_email VARCHAR(255),
    contact_phone VARCHAR(20),
    health_score INTEGER DEFAULT 0,             -- Performance metric (scans/week)
    cards_placed INTEGER DEFAULT 0,             -- Number of cards at venue
    last_activity TIMESTAMP,                    -- Last scan from this venue
    created_at TIMESTAMP DEFAULT NOW(),
    notes TEXT
);

CREATE INDEX idx_nests_city ON nests(city);
CREATE INDEX idx_nests_health_score ON nests(health_score);

Table: users

Subscriber profile aggregation

CREATE TABLE users (
    email VARCHAR(255) PRIMARY KEY,
    name VARCHAR(100),
    city VARCHAR(100),
    tags TEXT[],                                -- Array of tags
    referral_code VARCHAR(20) UNIQUE,           -- User's unique referral code
    total_entries INTEGER DEFAULT 0,
    verified_entries INTEGER DEFAULT 0,
    first_entry_date TIMESTAMP,
    last_entry_date TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_users_referral_code ON users(referral_code);

Table: referrals

Referral tracking system

CREATE TABLE referrals (
    referral_id SERIAL PRIMARY KEY,
    inviter_email VARCHAR(255) NOT NULL REFERENCES users(email),
    invitee_email VARCHAR(255) NOT NULL REFERENCES users(email),
    status VARCHAR(20) DEFAULT 'pending',       -- 'pending', 'verified', 'rewarded'
    created_at TIMESTAMP DEFAULT NOW(),
    verified_at TIMESTAMP,
    UNIQUE(inviter_email, invitee_email)
);

CREATE INDEX idx_referrals_inviter ON referrals(inviter_email);
CREATE INDEX idx_referrals_status ON referrals(status);

Table: prizes

Monthly winner fulfillment tracking

CREATE TABLE prizes (
    prize_id SERIAL PRIMARY KEY,
    month VARCHAR(7) NOT NULL,                  -- 'YYYY-MM'
    winner_email VARCHAR(255) NOT NULL REFERENCES users(email),
    winner_entry_id INTEGER REFERENCES entries(entry_id),
    fulfillment_status VARCHAR(20) DEFAULT 'pending',  -- 'pending', 'ordered', 'shipped', 'delivered'
    printify_order_id VARCHAR(100),             -- External order ID
    tracking_number VARCHAR(100),
    shipping_address TEXT,
    awarded_at TIMESTAMP DEFAULT NOW(),
    fulfilled_at TIMESTAMP,
    UNIQUE(month)                               -- One winner per month
);

CREATE INDEX idx_prizes_month ON prizes(month);
CREATE INDEX idx_prizes_fulfillment_status ON prizes(fulfillment_status);

Listmonk Database Schema

Note: Listmonk manages its own schema. Key tables:

  • subscribers - Email addresses and metadata
  • lists - Email lists (we'll use "Mad Monkey - Benny Campaign")
  • campaigns - Email campaign definitions
  • templates - Email templates (Welcome, Story, Reward)
  • subscriber_lists - Many-to-many relationship
Listmonk installer creates these automatically. Access via Listmonk UI or API.

Network & Port Configuration

3-Server Architecture

NGINX SSL Proxy (10.0.0.251): SSL Termination, Let's Encrypt, Reverse Proxy

Orchestrator (10.0.0.197): Claude Code, Projects, Documentation

BennyBeen (10.0.0.250): PostgreSQL, Shlink, Listmonk, n8n, Traefik

External IP: 76.150.65.61 (DYNAMIC)

Full Details: See Infrastructure Page

Orchestrator Server (10.0.0.197)

Purpose Path User
Claude Code CLI /home/indigoadmin/projects/ indigoadmin / root
Project Files /home/indigoadmin/projects/documentor/ indigoadmin
BennyBeen Project /home/indigoadmin/projects/BennyBeen/ indigoadmin
Workflow Project /home/indigoadmin/projects/workflow/ indigoadmin

NGINX Proxy Manager (10.0.0.251)

Service Port Domain Status
HTTPS (SSL termination) 443 All Mad Monkey domains Active
HTTP (redirect) 80 All domains Active
NPM Admin UI 81 Internal access only Active
NGINX Proxy Manager:
Container: jlesage/nginx-proxy-manager:latest
Admin UI: http://10.0.0.251:81
Admin Login: indigoproductions@yahoo.com
Proxy Hosts: 27 configured
SSH Access: ssh -i /root/.ssh/id_ed25519_to_250 indigoadmin@10.0.0.251

BennyBeen Docker Host (10.0.0.250)

Service Port Domain Container Name
PostgreSQL 5432 Internal only mm-postgres
Shlink 8081 mmlnk.us, admin.mmlnk.us mm-shlink
Traefik Dashboard 8080 traefik.mad-monkey-creations.com mm-traefik
Listmonk 9000 list.mad-monkey-creations.com mm-listmonk
Adventure (Landing Page) 80 (via Traefik) adventure.mad-monkey-creations.com mm-adventure
HTTP 80 All domains mm-traefik
HTTPS (SSL Termination) 443 All domains NGINX on 10.0.0.251

External Ports & Services

Port Protocol Service Status Notes
80 HTTP Web Traffic OPEN Redirects to 443
443 HTTPS Secure Web Traffic OPEN Let's Encrypt SSL
25 SMTP Mail Delivery FILTERED Blocked by Comcast ISP
465 SMTPS Secure SMTP OPEN SSL/TLS SMTP
587 Submission Mail Submission OPEN STARTTLS SMTP
993 IMAPS Secure IMAP OPEN SSL/TLS IMAP
5432 PostgreSQL Database INTERNAL ONLY Not exposed externally
Security Note: Port 25 is blocked by Comcast ISP. Use port 587 (submission) or 465 (SMTPS) for outbound email, or relay through ForwardMail.net.

Configuration Files

Docker Compose - All Services (Single Host)

File: /home/bennybeen/mad-monkey/docker-compose.yml

Host: bennybeen (10.0.0.250)

services:
  postgres:
    image: postgres:16-alpine
    container_name: mm-postgres
    restart: unless-stopped
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - ./postgres-data:/var/lib/postgresql/data
    networks:
      - mm-network

  n8n:
    image: n8nio/n8n:latest
    container_name: mm-n8n
    ports:
      - "5678:5678"
    # ... (see full config on server)

  shlink:
    image: shlinkio/shlink:stable
    container_name: mm-shlink
    ports:
      - "8081:8080"
    # ... (see full config on server)

  listmonk:
    image: listmonk/listmonk:latest
    container_name: mm-listmonk
    ports:
      - "9000:9000"
    # ... (see full config on server)

  adventure:
    image: nginx:alpine
    container_name: mm-adventure
    volumes:
      - ./landing-page:/usr/share/nginx/html:ro
    # ... (see full config on server)

  traefik:
    image: traefik:latest
    container_name: mm-traefik
    ports:
      - "80:80"
      - "8080:8080"
    # ... (see full config on server)

networks:
  mm-network:
    driver: bridge
Note: All containers run on single host 10.0.0.250. SSL termination handled by NGINX on 10.0.0.251.

Listmonk config.toml

Note: Listmonk uses environment variables from docker-compose.yml. The config.toml is auto-generated.

[app]
address = "0.0.0.0:9000"

[db]
host = "postgres"           # Docker service name (internal)
port = 5432
user = "mm_user"
password = "YOUR_STRONG_PASS"
database = "listmonk"
ssl_mode = "disable"
max_open = 25
max_idle = 25

[privacy]
individual_tracking = false
unsubscribe_header = true
allow_blocklist = true
allow_export = true

[upload]
provider = "filesystem"
filesystem_upload_path = "/listmonk/uploads"

[security]
enable_captcha = false

n8n Environment Variables

File: /home/bennybeen/mad-monkey/.env

# n8n Configuration
N8N_ENCRYPTION_KEY=your-encryption-key-here
N8N_HOST=n8n.mad-monkey-creations.com
N8N_PORT=5678
N8N_PROTOCOL=https
WEBHOOK_URL=https://hooks.mad-monkey-creations.com

# Database Connection (uses Docker service name)
DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=postgres
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_USER=mm_user
DB_POSTGRESDB_PASSWORD=YOUR_STRONG_PASS

# Mad Monkey Project
MM_SENDER_EMAIL=hello@mad-monkey-creations.com
MM_LANDING_BASE=https://adventure.mad-monkey-creations.com
MM_REWARD_URL=https://www.mad-monkey-creations.com/reward
MM_DISCOUNT_CODE=BANANAS10

# Listmonk API Credentials (use Docker service names internally)
# NOTE: Listmonk v6 API users store plaintext tokens, not hashed passwords
LISTMONK_URL=http://listmonk:9000
LISTMONK_API_USER=n8n_api
LISTMONK_API_TOKEN=madmonkey-api-token-2026
LISTMONK_LIST_ID=1

# Shlink API Credentials
SHLINK_URL=http://shlink:8080
SHLINK_API_KEY=71870798-7566-4fce-bff3-055a3a076710

# External access (from outside Docker network)
# LISTMONK_URL=http://10.0.0.250:9000
# SHLINK_URL=http://10.0.0.250:8081

NGINX Proxy Manager - docker-compose.yml

File: /home/indigoadmin/npm/docker-compose.yml on dockerhost-02 (10.0.0.251)

version: '3.8'
services:
  nginx-proxy-manager:
    image: jlesage/nginx-proxy-manager:latest
    container_name: nginx-proxy-manager
    restart: unless-stopped
    ports:
      - '80:8080'    # HTTP
      - '81:8181'    # Admin UI
      - '443:4443'   # HTTPS
    volumes:
      - ./config:/config:rw
    environment:
      - TZ=America/Chicago
      - DISABLE_IPV6=0
Note: Config stored in SQLite database at /config/database.sqlite

System Requirements

Minimum Hardware Requirements

Component Minimum Recommended
CPU 2 cores 4 cores
RAM 4 GB 8 GB
Storage 20 GB 50 GB SSD
Network 100 Mbps 1 Gbps

Software Requirements

Software Version Purpose
Docker 20.10+ Container runtime
Docker Compose 2.0+ Multi-container orchestration
PostgreSQL 16 Database (via Docker image)
n8n 1.107.4+ Workflow automation
Node.js 24.12.0 Runtime for scripts
Traefik 2.x Reverse proxy & SSL

DNS Configuration

mmlnk.us DNS Records

Type Name Value TTL
A @ 76.150.65.61 3600
A www 76.150.65.61 3600
A admin 76.150.65.61 3600
Note: External IP 76.150.65.61 is DYNAMIC. Update DNS if IP changes.

mad-monkey-creations.com DNS Records

Type Name Value Priority TTL
A @ 76.150.65.61 - 3600
A www 76.150.65.61 - 3600
A hooks 76.150.65.61 - 3600
A n8n 76.150.65.61 - 3600
A list 76.150.65.61 - 3600
A adventure 76.150.65.61 - 3600
MX @ mx1.forwardemail.net 10 3600
TXT @ "v=spf1 include:forwardmail.net ~all" - 3600
TXT _dmarc "v=DMARC1; p=quarantine; rua=mailto:dmarc@mad-monkey-creations.com" - 3600
TXT default._domainkey [DKIM key from ForwardMail.net] - 3600
Note: External IP 76.150.65.61 is DYNAMIC. All subdomains route through NGINX SSL Proxy (10.0.0.251) internally.

Security Configuration

IP Anonymization

Remove last octet from IPv4 addresses before storage:

// Example JavaScript function
function anonymizeIP(ip) {
    if (ip.includes(':')) {
        // IPv6: remove last 80 bits (keep /48 prefix)
        const parts = ip.split(':');
        return parts.slice(0, 3).join(':') + '::';
    } else {
        // IPv4: remove last octet
        const parts = ip.split('.');
        parts[3] = '0';
        return parts.join('.');
    }
}

// Example: 203.0.113.42 -> 203.0.113.0
// Example: 2001:db8:85a3::8a2e:370:7334 -> 2001:db8:85a3::

Rate Limiting Rules

Endpoint Limit Window Key
/scan 10 requests 1 minute IP address
/entry 3 submissions 1 hour Email
/entry 1 entry 30 days Email + month_bucket
API endpoints 100 requests 1 hour API key

CAPTCHA Configuration

Option 1: Cloudflare Turnstile

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>

Option 2: hCaptcha

<script src="https://js.hcaptcha.com/1/api.js"></script>
<div class="h-captcha" data-sitekey="YOUR_SITE_KEY"></div>
Enable CAPTCHA only for high-volume sources or after detecting suspicious patterns. Don't enable by default to reduce friction.

SSL/TLS Configuration

Certificate Provider: Let's Encrypt (via Traefik)

Renewal: Automatic (90-day certs, renew at 60 days)

Cipher Suites: TLS 1.2+ only

Traefik Labels for SSL:

labels:
  - "traefik.http.routers.myservice.tls.certresolver=letsencrypt"
  - "traefik.http.routers.myservice.tls=true"