- Paginate public APIs: campaigns, petitions, shifts, products, pages, shop - Add safety caps (take limits) to gallery ads, cuts, plans, donation pages - Add Pangolin connect-site endpoint with .env writer and site ID validation - Add formatting toolbar + keyboard shortcuts to shared doc editor - Fix Dockerfile to support su-exec privilege dropping for mounted volumes - Fix duplicate WebSocket headers in nginx API location block - Update MkDocs site build and social card assets Bunker Admin
3440 lines
128 KiB
HTML
3440 lines
128 KiB
HTML
|
||
|
||
<!doctype html>
|
||
<html lang="en" class="no-js">
|
||
<head>
|
||
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
|
||
<meta name="description" content="Deploy Changemaker Lite to production with Docker, SSL, backups, and monitoring.">
|
||
|
||
|
||
<meta name="author" content="Bunker Operations">
|
||
|
||
|
||
<link rel="canonical" href="https://cmlite.org/docs/deployment/">
|
||
|
||
|
||
<link rel="prev" href="../volunteer/achievements/">
|
||
|
||
|
||
<link rel="next" href="../architecture/">
|
||
|
||
|
||
|
||
|
||
|
||
<link rel="icon" href="../../assets/favicon.svg">
|
||
<meta name="generator" content="mkdocs-1.6.1, mkdocs-material-9.7.6">
|
||
|
||
|
||
|
||
<title>Deployment - Changemaker Lite</title>
|
||
|
||
|
||
|
||
<link rel="stylesheet" href="../../assets/stylesheets/main.484c7ddc.min.css">
|
||
|
||
|
||
<link rel="stylesheet" href="../../assets/stylesheets/palette.ab4e12ef.min.css">
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter:300,300i,400,400i,700,700i%7CJetBrains+Mono:400,400i,700,700i&display=fallback">
|
||
<style>:root{--md-text-font:"Inter";--md-code-font:"JetBrains Mono"}</style>
|
||
|
||
|
||
|
||
<link rel="stylesheet" href="../../stylesheets/extra.css">
|
||
|
||
<link rel="stylesheet" href="../../stylesheets/home.css">
|
||
|
||
<link rel="stylesheet" href="../../stylesheets/docs-comments.css">
|
||
|
||
<link rel="stylesheet" href="../../assets/css/video-player.css">
|
||
|
||
<link rel="stylesheet" href="../../assets/css/image-gallery.css">
|
||
|
||
<link rel="stylesheet" href="../../assets/css/payment-widgets.css">
|
||
|
||
<script>__md_scope=new URL("../..",location),__md_hash=e=>[...e].reduce(((e,_)=>(e<<5)-e+_.charCodeAt(0)),0),__md_get=(e,_=localStorage,t=__md_scope)=>JSON.parse(_.getItem(t.pathname+"."+e)),__md_set=(e,_,t=localStorage,a=__md_scope)=>{try{t.setItem(a.pathname+"."+e,JSON.stringify(_))}catch(e){}}</script>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<meta property="og:type" content="website" />
|
||
<meta property="og:title" content="Deployment - Changemaker Lite" />
|
||
<meta property="og:description" content="Deploy Changemaker Lite to production with Docker, SSL, backups, and monitoring." />
|
||
<meta property="og:image" content="https://cmlite.org/assets/images/social/docs/deployment/index.png" />
|
||
<meta property="og:image:type" content="image/png" />
|
||
<meta property="og:image:width" content="1200" />
|
||
<meta property="og:image:height" content="630" />
|
||
<meta property="og:url" content="https://cmlite.org/docs/deployment/" />
|
||
<meta property="twitter:card" content="summary_large_image" />
|
||
<meta property="twitter:title" content="Deployment - Changemaker Lite" />
|
||
<meta property="twitter:description" content="Deploy Changemaker Lite to production with Docker, SSL, backups, and monitoring." />
|
||
<meta property="twitter:image" content="https://cmlite.org/assets/images/social/docs/deployment/index.png" />
|
||
</head>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<body dir="ltr" data-md-color-scheme="slate" data-md-color-primary="deep-purple" data-md-color-accent="amber">
|
||
|
||
|
||
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
|
||
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
|
||
<label class="md-overlay" for="__drawer"></label>
|
||
<div data-md-component="skip">
|
||
|
||
|
||
<a href="#deployment" class="md-skip">
|
||
Skip to content
|
||
</a>
|
||
|
||
</div>
|
||
<div data-md-component="announce">
|
||
|
||
<aside class="md-banner">
|
||
<div class="md-banner__inner md-grid md-typeset">
|
||
|
||
<button class="md-banner__button md-icon" aria-label="Don't show this again">
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||
</button>
|
||
|
||
|
||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
|
||
<nav class="cm-header-nav" role="navigation" aria-label="Application">
|
||
<div class="cm-header-nav__brand">
|
||
<a href="#" data-path="/home" class="cm-header-nav__brand-link">
|
||
<span class="cm-header-nav__brand-text">Changemaker Lite</span>
|
||
</a>
|
||
</div>
|
||
<div class="cm-header-nav__links">
|
||
<div class="cm-header-nav__links-inner">
|
||
<a href="#" data-path="/home" class="cm-header-nav__link" data-nav-id="home"><span class="material-icons-outlined">home</span><span class="cm-header-nav__label">Home</span></a>
|
||
<a href="#" data-path="/campaigns" class="cm-header-nav__link" data-nav-id="campaigns"><span class="material-icons-outlined">send</span><span class="cm-header-nav__label">Campaigns</span></a>
|
||
<a href="#" data-path="/map" class="cm-header-nav__link" data-nav-id="map"><span class="material-icons-outlined">place</span><span class="cm-header-nav__label">Map</span></a>
|
||
<div class="cm-header-nav__dropdown">
|
||
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
|
||
<span class="material-icons-outlined">apps</span>
|
||
<span class="cm-header-nav__label">Scheduling</span>
|
||
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
|
||
</span>
|
||
<div class="cm-header-nav__dropdown-menu">
|
||
<a href="#" data-path="/shifts" class="cm-header-nav__dropdown-item" data-nav-id="shifts"><span class="material-icons-outlined">schedule</span><span>Shifts</span></a>
|
||
<a href="#" data-path="/events" class="cm-header-nav__dropdown-item" data-nav-id="events"><span class="material-icons-outlined">event</span><span>Calendar</span></a>
|
||
<a href="#" data-path="/polls" class="cm-header-nav__dropdown-item" data-nav-id="polls"><span class="material-icons-outlined">bar_chart</span><span>Polls</span></a>
|
||
</div>
|
||
</div>
|
||
<a href="#" data-path="/gallery" class="cm-header-nav__link" data-nav-id="gallery"><span class="material-icons-outlined">play_circle</span><span class="cm-header-nav__label">Gallery</span></a>
|
||
<div class="cm-header-nav__dropdown">
|
||
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
|
||
<span class="material-icons-outlined">account_balance_wallet</span>
|
||
<span class="cm-header-nav__label">Commerce</span>
|
||
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
|
||
</span>
|
||
<div class="cm-header-nav__dropdown-menu">
|
||
<a href="#" data-path="/pricing" class="cm-header-nav__dropdown-item" data-nav-id="pricing"><span class="material-icons-outlined">attach_money</span><span>Pricing</span></a>
|
||
<a href="#" data-path="/shop" class="cm-header-nav__dropdown-item" data-nav-id="shop"><span class="material-icons-outlined">shopping_bag</span><span>Shop</span></a>
|
||
<a href="#" data-path="/donate" class="cm-header-nav__dropdown-item" data-nav-id="donate"><span class="material-icons-outlined">favorite_border</span><span>Donate</span></a>
|
||
</div>
|
||
</div>
|
||
<a href="#" data-path="/wall-of-fame" class="cm-header-nav__link" data-nav-id="wall-of-fame"><span class="material-icons-outlined">emoji_events</span><span class="cm-header-nav__label">Wall of Fame</span></a>
|
||
<a href="#" data-path="/pages" class="cm-header-nav__link" data-nav-id="pages"><span class="material-icons-outlined">description</span><span class="cm-header-nav__label">Pages</span></a>
|
||
<a href="/" class="cm-header-nav__link" data-nav-id="landing"><span class="material-icons-outlined">language</span><span class="cm-header-nav__label">Website</span></a>
|
||
<a href="/docs/" class="cm-header-nav__link" data-nav-id="docs"><span class="material-icons-outlined">menu_book</span><span class="cm-header-nav__label">Docs</span></a>
|
||
<label for="__search" class="cm-header-nav__utility" title="Search">
|
||
<span class="material-icons-outlined">search</span>
|
||
</label>
|
||
<button class="cm-header-nav__utility" id="cm-palette-toggle" title="Toggle dark mode" type="button">
|
||
<span class="material-icons-outlined">dark_mode</span>
|
||
</button>
|
||
<a href="#" data-path="/login" class="cm-header-nav__link" id="cm-signin-link">
|
||
<span class="material-icons-outlined">login</span>
|
||
<span class="cm-header-nav__label">Sign In</span>
|
||
</a>
|
||
<div class="cm-header-nav__dropdown" id="cm-admin-dropdown" style="display:none">
|
||
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
|
||
<span class="material-icons-outlined">person</span>
|
||
<span class="cm-header-nav__label">Admin</span>
|
||
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
|
||
</span>
|
||
<div class="cm-header-nav__dropdown-menu cm-header-nav__dropdown-menu--right">
|
||
<a href="#" data-path="/app" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
|
||
<a href="#" data-path="/volunteer" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
|
||
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
|
||
<a href="#" data-path="/logout" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="cm-header-nav__hamburger" aria-label="Open navigation menu">
|
||
<span class="material-icons-outlined">menu</span>
|
||
</button>
|
||
</div>
|
||
</nav>
|
||
<div class="cm-header-nav__mobile-drawer" id="cm-mobile-drawer">
|
||
<div class="cm-header-nav__mobile-header">
|
||
<span class="cm-header-nav__brand-text">Changemaker Lite</span>
|
||
<button class="cm-header-nav__mobile-close" aria-label="Close navigation menu">
|
||
<span class="material-icons-outlined">close</span>
|
||
</button>
|
||
</div>
|
||
<div class="cm-header-nav__mobile-links">
|
||
<label for="__search" class="cm-header-nav__mobile-link" style="cursor:pointer">
|
||
<span class="material-icons-outlined">search</span>
|
||
<span>Search</span>
|
||
</label>
|
||
<button class="cm-header-nav__mobile-link cm-header-nav__utility-btn" id="cm-mobile-palette-toggle" type="button">
|
||
<span class="material-icons-outlined">dark_mode</span>
|
||
<span>Dark Mode</span>
|
||
</button>
|
||
<button class="cm-header-nav__mobile-link cm-header-nav__utility-btn" id="cm-docs-sidebar-toggle" type="button">
|
||
<span class="material-icons-outlined">menu_book</span>
|
||
<span>Docs Navigation</span>
|
||
</button>
|
||
<div class="cm-header-nav__mobile-divider"></div>
|
||
<a href="#" data-path="/home" class="cm-header-nav__mobile-link" data-nav-id="home"><span class="material-icons-outlined">home</span><span>Home</span></a>
|
||
<a href="#" data-path="/campaigns" class="cm-header-nav__mobile-link" data-nav-id="campaigns"><span class="material-icons-outlined">send</span><span>Campaigns</span></a>
|
||
<a href="#" data-path="/map" class="cm-header-nav__mobile-link" data-nav-id="map"><span class="material-icons-outlined">place</span><span>Map</span></a>
|
||
<div class="cm-header-nav__mobile-group" data-group-id="scheduling">
|
||
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
|
||
<span class="material-icons-outlined">apps</span>
|
||
<span style="flex:1">Scheduling</span>
|
||
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
|
||
</span>
|
||
<div class="cm-header-nav__mobile-group-children">
|
||
<a href="#" data-path="/shifts" class="cm-header-nav__mobile-link" data-nav-id="shifts" style="padding-left:48px"><span class="material-icons-outlined">schedule</span><span>Shifts</span></a>
|
||
<a href="#" data-path="/events" class="cm-header-nav__mobile-link" data-nav-id="events" style="padding-left:48px"><span class="material-icons-outlined">event</span><span>Calendar</span></a>
|
||
<a href="#" data-path="/polls" class="cm-header-nav__mobile-link" data-nav-id="polls" style="padding-left:48px"><span class="material-icons-outlined">bar_chart</span><span>Polls</span></a>
|
||
</div>
|
||
</div>
|
||
<a href="#" data-path="/gallery" class="cm-header-nav__mobile-link" data-nav-id="gallery"><span class="material-icons-outlined">play_circle</span><span>Gallery</span></a>
|
||
<div class="cm-header-nav__mobile-group" data-group-id="commerce">
|
||
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
|
||
<span class="material-icons-outlined">account_balance_wallet</span>
|
||
<span style="flex:1">Commerce</span>
|
||
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
|
||
</span>
|
||
<div class="cm-header-nav__mobile-group-children">
|
||
<a href="#" data-path="/pricing" class="cm-header-nav__mobile-link" data-nav-id="pricing" style="padding-left:48px"><span class="material-icons-outlined">attach_money</span><span>Pricing</span></a>
|
||
<a href="#" data-path="/shop" class="cm-header-nav__mobile-link" data-nav-id="shop" style="padding-left:48px"><span class="material-icons-outlined">shopping_bag</span><span>Shop</span></a>
|
||
<a href="#" data-path="/donate" class="cm-header-nav__mobile-link" data-nav-id="donate" style="padding-left:48px"><span class="material-icons-outlined">favorite_border</span><span>Donate</span></a>
|
||
</div>
|
||
</div>
|
||
<a href="#" data-path="/wall-of-fame" class="cm-header-nav__mobile-link" data-nav-id="wall-of-fame"><span class="material-icons-outlined">emoji_events</span><span>Wall of Fame</span></a>
|
||
<a href="#" data-path="/pages" class="cm-header-nav__mobile-link" data-nav-id="pages"><span class="material-icons-outlined">description</span><span>Pages</span></a>
|
||
<a href="/" class="cm-header-nav__mobile-link" data-nav-id="landing"><span class="material-icons-outlined">language</span><span>Website</span></a>
|
||
<a href="/docs/" class="cm-header-nav__mobile-link" data-nav-id="docs"><span class="material-icons-outlined">menu_book</span><span>Docs</span></a>
|
||
<div class="cm-header-nav__mobile-divider"></div>
|
||
<a href="#" data-path="/login" class="cm-header-nav__mobile-link" id="cm-mobile-signin-link">
|
||
<span class="material-icons-outlined">login</span>
|
||
<span>Sign In</span>
|
||
</a>
|
||
<div class="cm-header-nav__mobile-group" data-group-id="admin" id="cm-mobile-admin-group" style="display:none">
|
||
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
|
||
<span class="material-icons-outlined">person</span>
|
||
<span style="flex:1">Admin</span>
|
||
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
|
||
</span>
|
||
<div class="cm-header-nav__mobile-group-children">
|
||
<a href="#" data-path="/app" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
|
||
<a href="#" data-path="/volunteer" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
|
||
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
|
||
<a href="#" data-path="/logout" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="cm-header-nav__mobile-overlay" id="cm-mobile-overlay"></div>
|
||
<script>
|
||
(function() {
|
||
var h = location.hostname;
|
||
var base;
|
||
if (h === 'localhost' || h === '127.0.0.1') {
|
||
base = location.protocol + '//localhost:' + (3002 || 3000);
|
||
} else {
|
||
var parts = h.split('.');
|
||
if (parts.length >= 3) { parts[0] = 'app'; }
|
||
else { parts.unshift('app'); }
|
||
base = location.protocol + '//' + parts.join('.');
|
||
}
|
||
var links = document.querySelectorAll('[data-path]');
|
||
for (var i = 0; i < links.length; i++) {
|
||
links[i].setAttribute('href', base + links[i].getAttribute('data-path'));
|
||
}
|
||
// Highlight active nav link based on current path
|
||
var path = location.pathname;
|
||
var activeLink = null;
|
||
if (path.indexOf('/docs') === 0) activeLink = 'docs';
|
||
document.querySelectorAll('.cm-header-nav__link[data-nav-id], .cm-header-nav__mobile-link[data-nav-id]').forEach(function(el) {
|
||
if (el.getAttribute('data-nav-id') === activeLink) {
|
||
el.classList.add('cm-header-nav__link--active');
|
||
}
|
||
});
|
||
// Hamburger toggle
|
||
var hamburger = document.querySelector('.cm-header-nav__hamburger');
|
||
var drawer = document.getElementById('cm-mobile-drawer');
|
||
var overlay = document.getElementById('cm-mobile-overlay');
|
||
var closeBtn = document.querySelector('.cm-header-nav__mobile-close');
|
||
function openDrawer() { drawer.classList.add('open'); overlay.classList.add('open'); }
|
||
function closeDrawer() { drawer.classList.remove('open'); overlay.classList.remove('open'); }
|
||
if (hamburger) hamburger.addEventListener('click', openDrawer);
|
||
if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
|
||
if (overlay) overlay.addEventListener('click', closeDrawer);
|
||
// Mobile group expand/collapse toggles
|
||
document.querySelectorAll('.cm-header-nav__mobile-group-trigger').forEach(function(trigger) {
|
||
trigger.addEventListener('click', function() {
|
||
var group = this.closest('.cm-header-nav__mobile-group');
|
||
var children = group.querySelector('.cm-header-nav__mobile-group-children');
|
||
var isExpanded = group.classList.contains('expanded');
|
||
if (isExpanded) {
|
||
group.classList.remove('expanded');
|
||
children.style.display = 'none';
|
||
} else {
|
||
group.classList.add('expanded');
|
||
children.style.display = 'block';
|
||
}
|
||
});
|
||
});
|
||
// Auth-aware: show Admin dropdown for logged-in users, Sign In for guests.
|
||
// Uses hidden iframe + postMessage to read auth state from the app's origin.
|
||
function showAdminMenu() {
|
||
var s1 = document.getElementById('cm-signin-link');
|
||
var s2 = document.getElementById('cm-mobile-signin-link');
|
||
var a1 = document.getElementById('cm-admin-dropdown');
|
||
var a2 = document.getElementById('cm-mobile-admin-group');
|
||
if (s1) s1.style.display = 'none';
|
||
if (s2) s2.style.display = 'none';
|
||
if (a1) a1.style.display = '';
|
||
if (a2) a2.style.display = '';
|
||
}
|
||
// 1. Same-origin check (works when MkDocs served from same origin as app)
|
||
try {
|
||
var stored = localStorage.getItem('cml-auth');
|
||
if (stored) {
|
||
var parsed = JSON.parse(stored);
|
||
if (parsed && parsed.state && parsed.state.accessToken) {
|
||
showAdminMenu();
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
// 2. Cross-origin check via hidden iframe + postMessage
|
||
var iframe = document.createElement('iframe');
|
||
iframe.style.display = 'none';
|
||
iframe.src = base + '/auth-check.html?origin=' + encodeURIComponent(location.origin);
|
||
window.addEventListener('message', function(event) {
|
||
if (event.origin !== base) return;
|
||
if (event.data && event.data.type === 'cml-auth-status' && event.data.authenticated) {
|
||
showAdminMenu();
|
||
}
|
||
});
|
||
document.body.appendChild(iframe);
|
||
// Palette toggle (dark/light mode)
|
||
function togglePalette() {
|
||
var inputs = document.querySelectorAll('.cm-palette-container input[name="__palette"]');
|
||
for (var i = 0; i < inputs.length; i++) {
|
||
if (!inputs[i].checked) { inputs[i].click(); break; }
|
||
}
|
||
setTimeout(updatePaletteIcon, 50);
|
||
}
|
||
function updatePaletteIcon() {
|
||
var scheme = document.body.getAttribute('data-md-color-scheme') || 'default';
|
||
var isDark = scheme === 'slate';
|
||
var icon = isDark ? 'light_mode' : 'dark_mode';
|
||
document.querySelectorAll('#cm-palette-toggle .material-icons-outlined, #cm-mobile-palette-toggle .material-icons-outlined').forEach(function(el) {
|
||
el.textContent = icon;
|
||
});
|
||
var ml = document.querySelector('#cm-mobile-palette-toggle span:not(.material-icons-outlined)');
|
||
if (ml) ml.textContent = isDark ? 'Light Mode' : 'Dark Mode';
|
||
}
|
||
var ptBtn = document.getElementById('cm-palette-toggle');
|
||
var ptBtnM = document.getElementById('cm-mobile-palette-toggle');
|
||
if (ptBtn) ptBtn.addEventListener('click', togglePalette);
|
||
if (ptBtnM) ptBtnM.addEventListener('click', function() { togglePalette(); closeDrawer(); });
|
||
// Docs sidebar toggle (opens Material's docs navigation drawer)
|
||
var docsSidebarBtn = document.getElementById('cm-docs-sidebar-toggle');
|
||
if (docsSidebarBtn) {
|
||
docsSidebarBtn.addEventListener('click', function() {
|
||
closeDrawer();
|
||
var dt = document.getElementById('__drawer');
|
||
if (dt) { dt.checked = !dt.checked; dt.dispatchEvent(new Event('change')); }
|
||
});
|
||
}
|
||
// Close custom drawer when search label is clicked on mobile + auto-focus input
|
||
document.querySelectorAll('label[for="__search"]').forEach(function(el) {
|
||
el.addEventListener('click', function() {
|
||
if (el.classList.contains('md-search__overlay')) return; // overlay has its own handler
|
||
closeDrawer();
|
||
setTimeout(function() {
|
||
var input = document.querySelector('.md-search__input');
|
||
if (input) input.focus();
|
||
}, 150);
|
||
});
|
||
});
|
||
// Search activation: Material may open search via checkbox OR by focusing the
|
||
// input directly (varies by version). Detect both and mirror as body class.
|
||
// NOTE: search DOM elements render AFTER the announce block in the template,
|
||
// so we must defer element queries until DOMContentLoaded.
|
||
var searchToggle = null;
|
||
var searchInput = null;
|
||
// Apply search layout inline styles (CSS-in-stylesheet is unreliable due to
|
||
// cross-origin Material stylesheets overriding !important rules)
|
||
function applySearchLayout(active) {
|
||
var inner = document.querySelector('.md-search__inner');
|
||
var output = document.querySelector('.md-search__output');
|
||
var scrollwrap = document.querySelector('.md-search__scrollwrap');
|
||
if (!inner) return;
|
||
var isDesktop = window.matchMedia('(min-width: 60em)').matches;
|
||
if (active) {
|
||
inner.style.setProperty('display', 'flex', 'important');
|
||
inner.style.setProperty('flex-direction', 'column', 'important');
|
||
inner.style.setProperty('overflow', 'hidden', 'important');
|
||
// Firefox needs explicit height (not just max-height) for flex children to grow
|
||
if (isDesktop) {
|
||
inner.style.setProperty('height', 'calc(100vh - 64px)', 'important');
|
||
}
|
||
if (output) {
|
||
output.style.setProperty('position', 'relative', 'important');
|
||
output.style.setProperty('flex', '1 1 0px', 'important');
|
||
output.style.setProperty('min-height', '0', 'important');
|
||
output.style.setProperty('display', 'flex', 'important');
|
||
output.style.setProperty('flex-direction', 'column', 'important');
|
||
output.style.setProperty('overflow', 'hidden', 'important');
|
||
output.style.setProperty('width', '100%', 'important');
|
||
}
|
||
if (scrollwrap) {
|
||
scrollwrap.style.setProperty('max-height', 'none', 'important');
|
||
scrollwrap.style.setProperty('flex', '1 1 0px', 'important');
|
||
scrollwrap.style.setProperty('min-height', '0', 'important');
|
||
scrollwrap.style.setProperty('overflow-y', 'auto', 'important');
|
||
}
|
||
// Force search result elements visible + ensure proper stacking (Firefox)
|
||
var resultList = document.querySelector('.md-search-result__list');
|
||
if (resultList) {
|
||
resultList.style.setProperty('display', 'block', 'important');
|
||
resultList.style.setProperty('visibility', 'visible', 'important');
|
||
resultList.style.setProperty('opacity', '1', 'important');
|
||
resultList.style.setProperty('max-height', 'none', 'important');
|
||
resultList.style.setProperty('overflow', 'visible', 'important');
|
||
resultList.style.setProperty('color', 'var(--md-default-fg-color)', 'important');
|
||
}
|
||
var resultContainer = document.querySelector('.md-search-result');
|
||
if (resultContainer) {
|
||
resultContainer.style.setProperty('display', 'block', 'important');
|
||
resultContainer.style.setProperty('visibility', 'visible', 'important');
|
||
resultContainer.style.setProperty('opacity', '1', 'important');
|
||
}
|
||
// Ensure scrollwrap has z-index above overlay
|
||
if (scrollwrap) {
|
||
scrollwrap.style.setProperty('position', 'relative', 'important');
|
||
scrollwrap.style.setProperty('z-index', '1', 'important');
|
||
scrollwrap.style.setProperty('background', 'var(--md-default-bg-color)', 'important');
|
||
}
|
||
} else {
|
||
inner.style.removeProperty('display');
|
||
inner.style.removeProperty('flex-direction');
|
||
inner.style.removeProperty('overflow');
|
||
inner.style.removeProperty('height');
|
||
if (output) {
|
||
output.style.removeProperty('position');
|
||
output.style.removeProperty('flex');
|
||
output.style.removeProperty('min-height');
|
||
output.style.removeProperty('display');
|
||
output.style.removeProperty('flex-direction');
|
||
output.style.removeProperty('overflow');
|
||
output.style.removeProperty('width');
|
||
}
|
||
if (scrollwrap) {
|
||
scrollwrap.style.removeProperty('max-height');
|
||
scrollwrap.style.removeProperty('flex');
|
||
scrollwrap.style.removeProperty('min-height');
|
||
scrollwrap.style.removeProperty('overflow-y');
|
||
scrollwrap.style.removeProperty('position');
|
||
scrollwrap.style.removeProperty('z-index');
|
||
scrollwrap.style.removeProperty('background');
|
||
}
|
||
var resultList = document.querySelector('.md-search-result__list');
|
||
if (resultList) resultList.removeAttribute('style');
|
||
var resultContainer = document.querySelector('.md-search-result');
|
||
if (resultContainer) resultContainer.removeAttribute('style');
|
||
}
|
||
}
|
||
function activateSearch() {
|
||
document.body.classList.add('cm-search-active');
|
||
if (searchToggle) searchToggle.checked = true;
|
||
applySearchLayout(true);
|
||
}
|
||
function deactivateSearch() {
|
||
document.body.classList.remove('cm-search-active');
|
||
if (searchToggle) searchToggle.checked = false;
|
||
if (searchInput) searchInput.blur();
|
||
applySearchLayout(false);
|
||
}
|
||
function isSearchActive() {
|
||
return document.body.classList.contains('cm-search-active');
|
||
}
|
||
// Custom search labels in the cm-header-nav (these exist now, in announce block)
|
||
document.querySelectorAll('label[for="__search"]').forEach(function(lbl) {
|
||
lbl.addEventListener('click', function() {
|
||
if (lbl.classList.contains('md-search__overlay')) return;
|
||
setTimeout(function() { activateSearch(); if (searchInput) searchInput.focus(); }, 50);
|
||
});
|
||
});
|
||
// Deferred bindings: attach handlers to search elements once they exist in the DOM
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
searchToggle = document.getElementById('__search');
|
||
searchInput = document.querySelector('.md-search__input');
|
||
// Detect search open via input focus
|
||
if (searchInput) {
|
||
searchInput.addEventListener('focus', activateSearch);
|
||
}
|
||
// Detect search open via checkbox
|
||
if (searchToggle) {
|
||
searchToggle.addEventListener('change', function() {
|
||
if (searchToggle.checked) activateSearch(); else deactivateSearch();
|
||
});
|
||
}
|
||
// Click on overlay (md-search__overlay label) to dismiss search
|
||
var searchOverlay = document.querySelector('.md-search__overlay');
|
||
if (searchOverlay) {
|
||
searchOverlay.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (isSearchActive()) deactivateSearch();
|
||
});
|
||
}
|
||
});
|
||
// Click-outside to dismiss search (on document, works immediately)
|
||
document.addEventListener('mousedown', function(e) {
|
||
if (!isSearchActive()) return;
|
||
var panel = document.querySelector('.md-search__inner');
|
||
if (panel && panel.contains(e.target)) return;
|
||
// Let the overlay's own click handler deal with it
|
||
if (e.target.closest && e.target.closest('.md-search__overlay')) return;
|
||
if (e.target.closest && e.target.closest('label[for="__search"]')) return;
|
||
if (e.target.closest && e.target.closest('.cm-header-nav__utility')) return;
|
||
deactivateSearch();
|
||
});
|
||
// Escape key to dismiss
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape' && isSearchActive()) setTimeout(deactivateSearch, 50);
|
||
});
|
||
// Init palette icon + observe changes
|
||
setTimeout(updatePaletteIcon, 100);
|
||
new MutationObserver(function() { updatePaletteIcon(); })
|
||
.observe(document.body, { attributes: true, attributeFilter: ['data-md-color-scheme'] });
|
||
})();
|
||
</script>
|
||
<style>
|
||
.md-banner {
|
||
background: transparent !important;
|
||
color: #ffffff !important;
|
||
padding: 0 !important;
|
||
margin: 0 !important;
|
||
overflow: visible !important;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
position: relative;
|
||
z-index: 301;
|
||
}
|
||
.md-banner__inner {
|
||
overflow: visible !important;
|
||
margin: 0 !important;
|
||
padding: 0 !important;
|
||
max-width: 100% !important;
|
||
}
|
||
.md-banner__button {
|
||
display: none !important;
|
||
}
|
||
.cm-header-nav {
|
||
background: linear-gradient(135deg, #005a9c 0%, #007acc 100%);
|
||
height: 56px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 24px;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
position: relative;
|
||
z-index: 100;
|
||
box-sizing: border-box;
|
||
}
|
||
.cm-header-nav a {
|
||
color: rgba(255, 255, 255, 0.85) !important;
|
||
}
|
||
.cm-header-nav__brand-link {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
text-decoration: none !important;
|
||
color: #fff !important;
|
||
}
|
||
.cm-header-nav__brand-text {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #fff !important;
|
||
}
|
||
.cm-header-nav__links {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.cm-header-nav__links-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
.cm-header-nav__link {
|
||
color: rgba(255, 255, 255, 0.85) !important;
|
||
text-decoration: none !important;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 14px;
|
||
transition: color 0.2s, border-color 0.2s;
|
||
white-space: nowrap;
|
||
padding-bottom: 2px;
|
||
border-bottom: 2px solid transparent;
|
||
}
|
||
.cm-header-nav__link:hover {
|
||
color: #fff !important;
|
||
text-decoration: none !important;
|
||
}
|
||
.cm-header-nav__link--active,
|
||
.cm-header-nav__link--active:hover {
|
||
color: #fff !important;
|
||
font-weight: 600;
|
||
border-bottom-color: #fff;
|
||
}
|
||
.cm-header-nav__link .material-icons-outlined {
|
||
font-size: 16px;
|
||
}
|
||
.cm-header-nav__hamburger {
|
||
display: none;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 4px 8px;
|
||
color: #fff;
|
||
}
|
||
.cm-header-nav__hamburger .material-icons-outlined {
|
||
font-size: 24px;
|
||
}
|
||
/* Desktop dropdown menus */
|
||
.cm-header-nav__dropdown {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
.cm-header-nav__dropdown-trigger {
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.cm-header-nav__dropdown-trigger .cm-header-nav__chevron {
|
||
font-size: 14px;
|
||
transition: transform 0.2s;
|
||
}
|
||
.cm-header-nav__dropdown:hover .cm-header-nav__chevron {
|
||
transform: rotate(180deg);
|
||
}
|
||
.cm-header-nav__dropdown-menu {
|
||
display: none;
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
min-width: 180px;
|
||
background: #1b2838;
|
||
border-radius: 8px;
|
||
padding: 6px 0;
|
||
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
|
||
z-index: 100;
|
||
margin-top: 4px;
|
||
}
|
||
.cm-header-nav__dropdown:hover .cm-header-nav__dropdown-menu {
|
||
display: block;
|
||
}
|
||
.cm-header-nav__dropdown-menu--right {
|
||
left: auto;
|
||
right: 0;
|
||
}
|
||
.cm-header-nav__dropdown-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 16px;
|
||
color: rgba(255, 255, 255, 0.85) !important;
|
||
text-decoration: none !important;
|
||
font-size: 14px;
|
||
white-space: nowrap;
|
||
transition: background 0.15s;
|
||
}
|
||
.cm-header-nav__dropdown-item:hover {
|
||
background: rgba(255,255,255,0.1);
|
||
color: #fff !important;
|
||
text-decoration: none !important;
|
||
}
|
||
.cm-header-nav__dropdown-item .material-icons-outlined {
|
||
font-size: 16px;
|
||
}
|
||
/* Mobile drawer */
|
||
.cm-header-nav__mobile-drawer {
|
||
position: fixed;
|
||
top: 0;
|
||
right: -280px;
|
||
width: 280px;
|
||
height: 100vh;
|
||
background: #0d1b2a;
|
||
z-index: 10001;
|
||
transition: right 0.3s ease;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.cm-header-nav__mobile-drawer.open {
|
||
right: 0;
|
||
}
|
||
.cm-header-nav__mobile-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 24px;
|
||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||
background: #1b2838;
|
||
}
|
||
.cm-header-nav__mobile-close {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: rgba(255,255,255,0.85);
|
||
padding: 4px;
|
||
}
|
||
.cm-header-nav__mobile-links {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
padding: 16px 0;
|
||
}
|
||
.cm-header-nav__mobile-link {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 12px 24px;
|
||
color: rgba(255,255,255,0.85) !important;
|
||
text-decoration: none !important;
|
||
font-size: 15px;
|
||
border-radius: 4px;
|
||
}
|
||
.cm-header-nav__mobile-link:hover {
|
||
background: rgba(255,255,255,0.1);
|
||
color: #fff !important;
|
||
text-decoration: none !important;
|
||
}
|
||
.cm-header-nav__mobile-link--active {
|
||
color: #fff !important;
|
||
font-weight: 600;
|
||
background: rgba(255,255,255,0.1);
|
||
}
|
||
.cm-header-nav__mobile-link .material-icons-outlined {
|
||
font-size: 18px;
|
||
}
|
||
/* Mobile group expand/collapse */
|
||
.cm-header-nav__mobile-group-trigger {
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.cm-header-nav__mobile-chevron {
|
||
font-size: 14px !important;
|
||
transition: transform 0.2s;
|
||
}
|
||
.cm-header-nav__mobile-group.expanded .cm-header-nav__mobile-chevron {
|
||
transform: rotate(180deg);
|
||
}
|
||
.cm-header-nav__mobile-group-children {
|
||
display: none;
|
||
}
|
||
.cm-header-nav__mobile-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0,0,0,0.5);
|
||
z-index: 10000;
|
||
}
|
||
.cm-header-nav__mobile-overlay.open {
|
||
display: block;
|
||
}
|
||
@media (max-width: 768px) {
|
||
.cm-header-nav { padding: 0 16px; }
|
||
.cm-header-nav__links-inner { display: none; }
|
||
.cm-header-nav__hamburger { display: block; }
|
||
.cm-header-nav__dropdown-menu { display: none !important; }
|
||
}
|
||
/* Sidebar sticky offset = 0 since blue header scrolls away */
|
||
:root {
|
||
--md-header-height: 0px;
|
||
}
|
||
/* Hidden Material header — keeps search anchored near tabs */
|
||
.md-header--cm-hidden {
|
||
height: 0 !important;
|
||
min-height: 0 !important;
|
||
padding: 0 !important;
|
||
margin: 0 !important;
|
||
border: 0 !important;
|
||
overflow: visible !important;
|
||
background: transparent !important;
|
||
box-shadow: none !important;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 200;
|
||
}
|
||
|
||
/* === DESKTOP SEARCH (>= 60em / 960px) === */
|
||
@media screen and (min-width: 60em) {
|
||
/* Fixed dropdown panel — layout (flex) applied via JS inline styles */
|
||
body.cm-search-active .md-header--cm-hidden .md-search__inner {
|
||
position: fixed !important;
|
||
top: 48px !important;
|
||
right: 16px !important;
|
||
left: auto !important;
|
||
width: min(34rem, calc(100vw - 32px)) !important;
|
||
max-height: calc(100vh - 64px) !important;
|
||
background: var(--md-default-bg-color) !important;
|
||
border-radius: 0 0 8px 8px !important;
|
||
box-shadow: 0 4px 24px rgba(0,0,0,0.25) !important;
|
||
z-index: 300 !important;
|
||
opacity: 1 !important;
|
||
transform: none !important;
|
||
visibility: visible !important;
|
||
pointer-events: auto !important;
|
||
clip-path: none !important;
|
||
}
|
||
|
||
/* Dark overlay behind search panel — catches clicks to dismiss */
|
||
body.cm-search-active .md-header--cm-hidden .md-search__overlay {
|
||
position: fixed !important;
|
||
top: 0 !important;
|
||
left: 0 !important;
|
||
width: 100vw !important;
|
||
height: 100vh !important;
|
||
background: rgba(0,0,0,0.54) !important;
|
||
opacity: 1 !important;
|
||
z-index: 299 !important;
|
||
border-radius: 0 !important;
|
||
transform: none !important;
|
||
cursor: default !important;
|
||
pointer-events: auto !important;
|
||
}
|
||
}
|
||
|
||
/* === MOBILE SEARCH (< 60em / 960px) === */
|
||
@media screen and (max-width: 59.984375em) {
|
||
/* Full-screen search takeover — layout (flex) applied via JS inline styles */
|
||
body.cm-search-active .md-header--cm-hidden .md-search__inner {
|
||
position: fixed !important;
|
||
top: 0 !important;
|
||
left: 0 !important;
|
||
right: 0 !important;
|
||
bottom: 0 !important;
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
opacity: 1 !important;
|
||
transform: none !important;
|
||
visibility: visible !important;
|
||
pointer-events: auto !important;
|
||
z-index: 300 !important;
|
||
background: var(--md-default-bg-color) !important;
|
||
clip-path: none !important;
|
||
}
|
||
}
|
||
|
||
/* Force search elements visible when active (layout handled by JS inline styles) */
|
||
body.cm-search-active .md-header--cm-hidden .md-search {
|
||
display: block !important;
|
||
visibility: visible !important;
|
||
opacity: 1 !important;
|
||
overflow: visible !important;
|
||
}
|
||
body.cm-search-active .md-header--cm-hidden .md-search__output {
|
||
opacity: 1 !important;
|
||
visibility: visible !important;
|
||
clip-path: none !important;
|
||
transform: none !important;
|
||
}
|
||
.cm-palette-container {
|
||
height: 0 !important;
|
||
overflow: hidden !important;
|
||
}
|
||
/* Material tabs: sticky at viewport top when blue header scrolls away */
|
||
.md-tabs {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 99;
|
||
}
|
||
/* On mobile, hide tabs (sidebar provides navigation) */
|
||
@media (max-width: 768px) {
|
||
.md-tabs { display: none; }
|
||
}
|
||
/* Utility icon styling */
|
||
.cm-header-nav__utility {
|
||
background: none;
|
||
border: none;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
cursor: pointer;
|
||
padding: 4px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
transition: color 0.2s;
|
||
}
|
||
.cm-header-nav__utility:hover { color: #fff; }
|
||
.cm-header-nav__utility .material-icons-outlined { font-size: 20px; }
|
||
.cm-header-nav__utility-btn {
|
||
background: none;
|
||
border: none;
|
||
color: rgba(255,255,255,0.85);
|
||
cursor: pointer;
|
||
font-size: 15px;
|
||
font-family: inherit;
|
||
width: 100%;
|
||
text-align: left;
|
||
}
|
||
.cm-header-nav__mobile-divider {
|
||
height: 1px;
|
||
background: rgba(255,255,255,0.1);
|
||
margin: 8px 24px;
|
||
}
|
||
</style>
|
||
|
||
</div>
|
||
|
||
<script>var el=document.querySelector("[data-md-component=announce]");if(el){var content=el.querySelector(".md-typeset");__md_hash(content.innerHTML)===__md_get("__announce")&&(el.hidden=!0)}</script>
|
||
|
||
</aside>
|
||
|
||
</div>
|
||
|
||
|
||
<header class="md-header md-header--cm-hidden" data-md-component="header">
|
||
<div class="cm-palette-container">
|
||
|
||
|
||
<form class="md-header__option" data-md-component="palette">
|
||
|
||
|
||
|
||
|
||
<input class="md-option" data-md-color-media="" data-md-color-scheme="slate" data-md-color-primary="deep-purple" data-md-color-accent="amber" aria-label="Switch to light mode" type="radio" name="__palette" id="__palette_0">
|
||
|
||
<label class="md-header__button md-icon" title="Switch to light mode" for="__palette_1" hidden>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m17.75 4.09-2.53 1.94.91 3.06-2.63-1.81-2.63 1.81.91-3.06-2.53-1.94L12.44 4l1.06-3 1.06 3zm3.5 6.91-1.64 1.25.59 1.98-1.7-1.17-1.7 1.17.59-1.98L15.75 11l2.06-.05L18.5 9l.69 1.95zm-2.28 4.95c.83-.08 1.72 1.1 1.19 1.85-.32.45-.66.87-1.08 1.27C15.17 23 8.84 23 4.94 19.07c-3.91-3.9-3.91-10.24 0-14.14.4-.4.82-.76 1.27-1.08.75-.53 1.93.36 1.85 1.19-.27 2.86.69 5.83 2.89 8.02a9.96 9.96 0 0 0 8.02 2.89m-1.64 2.02a12.08 12.08 0 0 1-7.8-3.47c-2.17-2.19-3.33-5-3.49-7.82-2.81 3.14-2.7 7.96.31 10.98 3.02 3.01 7.84 3.12 10.98.31"/></svg>
|
||
</label>
|
||
|
||
|
||
|
||
|
||
|
||
<input class="md-option" data-md-color-media="" data-md-color-scheme="default" data-md-color-primary="deep-purple" data-md-color-accent="amber" aria-label="Switch to dark mode" type="radio" name="__palette" id="__palette_1">
|
||
|
||
<label class="md-header__button md-icon" title="Switch to dark mode" for="__palette_0" hidden>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3m0-7 2.39 3.42C13.65 5.15 12.84 5 12 5s-1.65.15-2.39.42zM3.34 7l4.16-.35A7.2 7.2 0 0 0 5.94 8.5c-.44.74-.69 1.5-.83 2.29zm.02 10 1.76-3.77a7.131 7.131 0 0 0 2.38 4.14zM20.65 7l-1.77 3.79a7.02 7.02 0 0 0-2.38-4.15zm-.01 10-4.14.36c.59-.51 1.12-1.14 1.54-1.86.42-.73.69-1.5.83-2.29zM12 22l-2.41-3.44c.74.27 1.55.44 2.41.44.82 0 1.63-.17 2.37-.44z"/></svg>
|
||
</label>
|
||
|
||
|
||
</form>
|
||
|
||
|
||
</div>
|
||
|
||
<div class="md-search" data-md-component="search" role="dialog">
|
||
<label class="md-search__overlay" for="__search"></label>
|
||
<div class="md-search__inner" role="search">
|
||
<form class="md-search__form" name="search">
|
||
<input type="text" class="md-search__input" name="query" aria-label="Search" placeholder="Search" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" required>
|
||
<label class="md-search__icon md-icon" for="__search">
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg>
|
||
</label>
|
||
<nav class="md-search__options" aria-label="Search">
|
||
|
||
<a href="javascript:void(0)" class="md-search__icon md-icon" title="Share" aria-label="Share" data-clipboard data-clipboard-text="" data-md-component="search-share" tabindex="-1">
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3 3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66 0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08"/></svg>
|
||
</a>
|
||
|
||
<button type="reset" class="md-search__icon md-icon" title="Clear" aria-label="Clear" tabindex="-1">
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||
</button>
|
||
</nav>
|
||
|
||
<div class="md-search__suggest" data-md-component="search-suggest"></div>
|
||
|
||
</form>
|
||
<div class="md-search__output">
|
||
<div class="md-search__scrollwrap" tabindex="0" data-md-scrollfix>
|
||
<div class="md-search-result" data-md-component="search-result">
|
||
<div class="md-search-result__meta">
|
||
Initializing search
|
||
</div>
|
||
<ol class="md-search-result__list" role="presentation"></ol>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</header>
|
||
|
||
<div class="md-container" data-md-component="container">
|
||
|
||
|
||
|
||
|
||
<nav class="md-tabs" aria-label="Tabs" data-md-component="tabs">
|
||
<div class="md-grid">
|
||
<ul class="md-tabs__list">
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-tabs__item">
|
||
<a href="../.." class="md-tabs__link">
|
||
|
||
|
||
|
||
|
||
|
||
Home
|
||
|
||
</a>
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-tabs__item md-tabs__item--active">
|
||
<a href="../" class="md-tabs__link">
|
||
|
||
|
||
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 21.5c-1.35-.85-3.8-1.5-5.5-1.5-1.65 0-3.35.3-4.75 1.05-.1.05-.15.05-.25.05-.25 0-.5-.25-.5-.5V6c.6-.45 1.25-.75 2-1 1.11-.35 2.33-.5 3.5-.5 1.95 0 4.05.4 5.5 1.5 1.45-1.1 3.55-1.5 5.5-1.5 1.17 0 2.39.15 3.5.5.75.25 1.4.55 2 1v14.6c0 .25-.25.5-.5.5-.1 0-.15 0-.25-.05-1.4-.75-3.1-1.05-4.75-1.05-1.7 0-4.15.65-5.5 1.5M12 8v11.5c1.35-.85 3.8-1.5 5.5-1.5 1.2 0 2.4.15 3.5.5V7c-1.1-.35-2.3-.5-3.5-.5-1.7 0-4.15.65-5.5 1.5m1 3.5c1.11-.68 2.6-1 4.5-1 .91 0 1.76.09 2.5.28V9.23c-.87-.15-1.71-.23-2.5-.23q-2.655 0-4.5.84zm4.5.17c-1.71 0-3.21.26-4.5.79v1.69c1.11-.65 2.6-.99 4.5-.99 1.04 0 1.88.08 2.5.24v-1.5c-.87-.16-1.71-.23-2.5-.23m2.5 2.9c-.87-.16-1.71-.24-2.5-.24-1.83 0-3.33.27-4.5.8v1.69c1.11-.66 2.6-.99 4.5-.99 1.04 0 1.88.08 2.5.24z"/></svg>
|
||
|
||
|
||
Docs
|
||
|
||
</a>
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-tabs__item">
|
||
<a href="../../blog/" class="md-tabs__link">
|
||
|
||
|
||
|
||
|
||
|
||
Blog
|
||
|
||
</a>
|
||
</li>
|
||
|
||
|
||
|
||
|
||
</ul>
|
||
</div>
|
||
</nav>
|
||
|
||
|
||
<main class="md-main" data-md-component="main">
|
||
<div class="md-main__inner md-grid">
|
||
|
||
|
||
|
||
<div class="md-sidebar md-sidebar--primary" data-md-component="sidebar" data-md-type="navigation" >
|
||
<div class="md-sidebar__scrollwrap">
|
||
<div class="md-sidebar__inner">
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<nav class="md-nav md-nav--primary md-nav--lifted" aria-label="Navigation" data-md-level="0">
|
||
<label class="md-nav__title" for="__drawer">
|
||
<a href="../.." title="Changemaker Lite" class="md-nav__button md-logo" aria-label="Changemaker Lite" data-md-component="logo">
|
||
|
||
<img src="../../assets/logo.svg" alt="logo">
|
||
|
||
</a>
|
||
Changemaker Lite
|
||
</label>
|
||
|
||
<div class="md-nav__source">
|
||
<a href="https://gitea.bnkops.com/admin/changemaker.lite" title="Go to repository" class="md-source" data-md-component="source">
|
||
<div class="md-source__icon md-icon">
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
|
||
</div>
|
||
<div class="md-source__repository">
|
||
changemaker.lite
|
||
</div>
|
||
</a>
|
||
</div>
|
||
|
||
<ul class="md-nav__list" data-md-scrollfix>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item">
|
||
<a href="../.." class="md-nav__link">
|
||
|
||
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
Home
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
</a>
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item md-nav__item--active md-nav__item--section md-nav__item--nested">
|
||
|
||
|
||
|
||
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_2" checked>
|
||
|
||
|
||
<div class="md-nav__link md-nav__container">
|
||
<a href="../" class="md-nav__link ">
|
||
|
||
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 21.5c-1.35-.85-3.8-1.5-5.5-1.5-1.65 0-3.35.3-4.75 1.05-.1.05-.15.05-.25.05-.25 0-.5-.25-.5-.5V6c.6-.45 1.25-.75 2-1 1.11-.35 2.33-.5 3.5-.5 1.95 0 4.05.4 5.5 1.5 1.45-1.1 3.55-1.5 5.5-1.5 1.17 0 2.39.15 3.5.5.75.25 1.4.55 2 1v14.6c0 .25-.25.5-.5.5-.1 0-.15 0-.25-.05-1.4-.75-3.1-1.05-4.75-1.05-1.7 0-4.15.65-5.5 1.5M12 8v11.5c1.35-.85 3.8-1.5 5.5-1.5 1.2 0 2.4.15 3.5.5V7c-1.1-.35-2.3-.5-3.5-.5-1.7 0-4.15.65-5.5 1.5m1 3.5c1.11-.68 2.6-1 4.5-1 .91 0 1.76.09 2.5.28V9.23c-.87-.15-1.71-.23-2.5-.23q-2.655 0-4.5.84zm4.5.17c-1.71 0-3.21.26-4.5.79v1.69c1.11-.65 2.6-.99 4.5-.99 1.04 0 1.88.08 2.5.24v-1.5c-.87-.16-1.71-.23-2.5-.23m2.5 2.9c-.87-.16-1.71-.24-2.5-.24-1.83 0-3.33.27-4.5.8v1.69c1.11-.66 2.6-.99 4.5-.99 1.04 0 1.88.08 2.5.24z"/></svg>
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
Docs
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
</a>
|
||
|
||
|
||
<label class="md-nav__link " for="__nav_2" id="__nav_2_label" tabindex="">
|
||
<span class="md-nav__icon md-icon"></span>
|
||
</label>
|
||
|
||
</div>
|
||
|
||
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_2_label" aria-expanded="true">
|
||
<label class="md-nav__title" for="__nav_2">
|
||
<span class="md-nav__icon md-icon"></span>
|
||
|
||
|
||
Docs
|
||
|
||
|
||
</label>
|
||
<ul class="md-nav__list" data-md-scrollfix>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
|
||
|
||
|
||
|
||
|
||
|
||
<a href="../getting-started/" class="md-nav__link">
|
||
|
||
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
Getting Started
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
|
||
<span class="md-nav__icon md-icon"></span>
|
||
|
||
</a>
|
||
|
||
|
||
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
|
||
|
||
|
||
|
||
|
||
|
||
<a href="../admin/" class="md-nav__link">
|
||
|
||
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
Admin Guide
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
|
||
<span class="md-nav__icon md-icon"></span>
|
||
|
||
</a>
|
||
|
||
|
||
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
|
||
|
||
|
||
|
||
|
||
|
||
<a href="../user-guide/" class="md-nav__link">
|
||
|
||
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
User Guide
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
|
||
<span class="md-nav__icon md-icon"></span>
|
||
|
||
</a>
|
||
|
||
|
||
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
|
||
|
||
|
||
|
||
|
||
|
||
<a href="../volunteer/" class="md-nav__link">
|
||
|
||
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
Volunteer Guide
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
|
||
<span class="md-nav__icon md-icon"></span>
|
||
|
||
</a>
|
||
|
||
|
||
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item md-nav__item--active md-nav__item--nested">
|
||
|
||
|
||
|
||
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_2_6" checked>
|
||
|
||
|
||
<div class="md-nav__link md-nav__container">
|
||
<a href="./" class="md-nav__link md-nav__link--active">
|
||
|
||
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.81 10.25c-.06-.04-.56-.43-1.64-.43-.28 0-.56.03-.84.08-.21-1.4-1.38-2.11-1.43-2.14l-.29-.17-.18.27c-.24.36-.43.77-.51 1.19-.2.8-.08 1.56.33 2.21-.49.28-1.29.35-1.46.35H2.62c-.34 0-.62.28-.62.63 0 1.15.18 2.3.58 3.38.45 1.19 1.13 2.07 2 2.61.98.6 2.59.94 4.42.94.79 0 1.61-.07 2.42-.22 1.12-.2 2.2-.59 3.19-1.16A8.3 8.3 0 0 0 16.78 16c1.05-1.17 1.67-2.5 2.12-3.65h.19c1.14 0 1.85-.46 2.24-.85.26-.24.45-.53.59-.87l.08-.24zm-17.96.99h1.76c.08 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16H3.85c-.09 0-.16.07-.16.16v1.58c.01.09.07.16.16.16m2.43 0h1.76c.08 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16H6.28c-.09 0-.16.07-.16.16v1.58c.01.09.07.16.16.16m2.47 0h1.75c.1 0 .17-.07.17-.16V9.5c0-.08-.06-.16-.17-.16H8.75c-.08 0-.15.07-.15.16v1.58c0 .09.06.16.15.16m2.44 0h1.77c.08 0 .15-.07.15-.16V9.5c0-.08-.06-.16-.15-.16h-1.77c-.08 0-.15.07-.15.16v1.58c0 .09.07.16.15.16M6.28 9h1.76c.08 0 .16-.09.16-.18V7.25c0-.09-.07-.16-.16-.16H6.28c-.09 0-.16.06-.16.16v1.57c.01.09.07.18.16.18m2.47 0h1.75c.1 0 .17-.09.17-.18V7.25c0-.09-.06-.16-.17-.16H8.75c-.08 0-.15.06-.15.16v1.57c0 .09.06.18.15.18m2.44 0h1.77c.08 0 .15-.09.15-.18V7.25c0-.09-.07-.16-.15-.16h-1.77c-.08 0-.15.06-.15.16v1.57c0 .09.07.18.15.18m0-2.28h1.77c.08 0 .15-.07.15-.16V5c0-.1-.07-.17-.15-.17h-1.77c-.08 0-.15.06-.15.17v1.56c0 .08.07.16.15.16m2.46 4.52h1.76c.09 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16h-1.76c-.08 0-.15.07-.15.16v1.58c0 .09.07.16.15.16"/></svg>
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
Deployment
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
</a>
|
||
|
||
</div>
|
||
|
||
<nav class="md-nav" data-md-level="2" aria-labelledby="__nav_2_6_label" aria-expanded="true">
|
||
<label class="md-nav__title" for="__nav_2_6">
|
||
<span class="md-nav__icon md-icon"></span>
|
||
|
||
|
||
Deployment
|
||
|
||
|
||
</label>
|
||
<ul class="md-nav__list" data-md-scrollfix>
|
||
|
||
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
|
||
|
||
|
||
|
||
|
||
|
||
<a href="../architecture/" class="md-nav__link">
|
||
|
||
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
Architecture
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
|
||
<span class="md-nav__icon md-icon"></span>
|
||
|
||
</a>
|
||
|
||
|
||
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
|
||
|
||
|
||
|
||
|
||
|
||
<a href="../services/" class="md-nav__link">
|
||
|
||
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
Services
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
|
||
<span class="md-nav__icon md-icon"></span>
|
||
|
||
</a>
|
||
|
||
|
||
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
|
||
|
||
|
||
|
||
|
||
|
||
<a href="../api/" class="md-nav__link">
|
||
|
||
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
API Reference
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
|
||
<span class="md-nav__icon md-icon"></span>
|
||
|
||
</a>
|
||
|
||
|
||
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
|
||
|
||
|
||
|
||
|
||
|
||
<a href="../troubleshooting/" class="md-nav__link">
|
||
|
||
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
Troubleshooting
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
|
||
<span class="md-nav__icon md-icon"></span>
|
||
|
||
</a>
|
||
|
||
|
||
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item">
|
||
<a href="../phil/" class="md-nav__link">
|
||
|
||
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
Philosophy
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
</a>
|
||
</li>
|
||
|
||
|
||
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
|
||
|
||
|
||
|
||
|
||
|
||
<a href="../../blog/" class="md-nav__link">
|
||
|
||
|
||
|
||
<span class="md-ellipsis">
|
||
|
||
|
||
Blog
|
||
|
||
|
||
|
||
</span>
|
||
|
||
|
||
|
||
|
||
<span class="md-nav__icon md-icon"></span>
|
||
|
||
</a>
|
||
|
||
|
||
|
||
</li>
|
||
|
||
|
||
|
||
</ul>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
<div class="md-sidebar md-sidebar--secondary" data-md-component="sidebar" data-md-type="toc" >
|
||
<div class="md-sidebar__scrollwrap">
|
||
<div class="md-sidebar__inner">
|
||
|
||
|
||
|
||
|
||
<nav class="md-nav md-nav--secondary" aria-label="On this page">
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<label class="md-nav__title" for="__toc">
|
||
<span class="md-nav__icon md-icon"></span>
|
||
On this page
|
||
</label>
|
||
<ul class="md-nav__list" data-md-component="toc" data-md-scrollfix>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#architecture-overview" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Architecture Overview
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#exposure-methods" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Exposure Methods
|
||
|
||
</span>
|
||
</a>
|
||
|
||
<nav class="md-nav" aria-label="Exposure Methods">
|
||
<ul class="md-nav__list">
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#pangolin" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Option 1: Pangolin + Newt Tunnel (Recommended)
|
||
|
||
</span>
|
||
</a>
|
||
|
||
<nav class="md-nav" aria-label="Option 1: Pangolin + Newt Tunnel (Recommended)">
|
||
<ul class="md-nav__list">
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#step-1-configure-pangolin-credentials" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Step 1: Configure Pangolin Credentials
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#step-2-create-a-site-in-pangolin" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Step 2: Create a Site in Pangolin
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#step-3-update-env-with-site-credentials" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Step 3: Update .env with Site Credentials
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#step-4-start-the-newt-container" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Step 4: Start the Newt Container
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#step-5-create-public-http-resources" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Step 5: Create Public HTTP Resources
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#step-6-update-cors-for-production" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Step 6: Update CORS for Production
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#step-7-verify" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Step 7: Verify
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#cloudflare" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Option 2: Cloudflare Tunnel
|
||
|
||
</span>
|
||
</a>
|
||
|
||
<nav class="md-nav" aria-label="Option 2: Cloudflare Tunnel">
|
||
<ul class="md-nav__list">
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#setup" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Setup
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#direct" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Option 3: Direct DNS + Reverse Proxy
|
||
|
||
</span>
|
||
</a>
|
||
|
||
<nav class="md-nav" aria-label="Option 3: Direct DNS + Reverse Proxy">
|
||
<ul class="md-nav__list">
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#setup_1" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Setup
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#tailscale" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Option 4: Tailscale / WireGuard (Private Access)
|
||
|
||
</span>
|
||
</a>
|
||
|
||
<nav class="md-nav" aria-label="Option 4: Tailscale / WireGuard (Private Access)">
|
||
<ul class="md-nav__list">
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#tailscale-setup" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Tailscale Setup
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#wireguard-setup" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
WireGuard Setup
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#production-checklist" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Production Checklist
|
||
|
||
</span>
|
||
</a>
|
||
|
||
<nav class="md-nav" aria-label="Production Checklist">
|
||
<ul class="md-nav__list">
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#security" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Security
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#networking" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Networking
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#services" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Services
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#backups" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Backups
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#monitoring-optional" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Monitoring (Optional)
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#backups_1" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Backups
|
||
|
||
</span>
|
||
</a>
|
||
|
||
<nav class="md-nav" aria-label="Backups">
|
||
<ul class="md-nav__list">
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#running-a-backup" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Running a Backup
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#options" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Options
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#automated-backups" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Automated Backups
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#restore" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Restore
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#monitoring" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Monitoring
|
||
|
||
</span>
|
||
</a>
|
||
|
||
<nav class="md-nav" aria-label="Monitoring">
|
||
<ul class="md-nav__list">
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#starting-the-monitoring-stack" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Starting the Monitoring Stack
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#pre-configured-dashboards" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Pre-configured Dashboards
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#custom-metrics" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Custom Metrics
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#alert-rules" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Alert Rules
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#upgrading" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Upgrading
|
||
|
||
</span>
|
||
</a>
|
||
|
||
<nav class="md-nav" aria-label="Upgrading">
|
||
<ul class="md-nav__list">
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#pulling-updates" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Pulling Updates
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#database-migrations" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Database Migrations
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#troubleshooting-production-issues" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Troubleshooting Production Issues
|
||
|
||
</span>
|
||
</a>
|
||
|
||
<nav class="md-nav" aria-label="Troubleshooting Production Issues">
|
||
<ul class="md-nav__list">
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#pangolin-302-redirects-instead-of-content" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Pangolin: 302 Redirects Instead of Content
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#cors-errors" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
CORS Errors
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#newt-wont-connect" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Newt Won't Connect
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
<li class="md-nav__item">
|
||
<a href="#services-unreachable-via-tunnel" class="md-nav__link">
|
||
<span class="md-ellipsis">
|
||
|
||
Services Unreachable via Tunnel
|
||
|
||
</span>
|
||
</a>
|
||
|
||
</li>
|
||
|
||
</ul>
|
||
</nav>
|
||
|
||
</li>
|
||
|
||
</ul>
|
||
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
<div class="md-content" data-md-component="content">
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<nav class="md-path" aria-label="Navigation" >
|
||
<ol class="md-path__list">
|
||
|
||
|
||
|
||
|
||
<li class="md-path__item">
|
||
<a href="../.." class="md-path__link">
|
||
|
||
<span class="md-ellipsis">
|
||
Home
|
||
</span>
|
||
|
||
</a>
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-path__item">
|
||
<a href="../" class="md-path__link">
|
||
|
||
<span class="md-ellipsis">
|
||
Docs
|
||
</span>
|
||
|
||
</a>
|
||
</li>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<li class="md-path__item">
|
||
<a href="./" class="md-path__link">
|
||
|
||
<span class="md-ellipsis">
|
||
Deployment
|
||
</span>
|
||
|
||
</a>
|
||
</li>
|
||
|
||
|
||
|
||
|
||
</ol>
|
||
</nav>
|
||
|
||
|
||
<article class="md-content__inner md-typeset">
|
||
|
||
|
||
|
||
<nav class="md-tags" >
|
||
|
||
|
||
|
||
|
||
|
||
<span class="md-tag">deployment</span>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<span class="md-tag">docker</span>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<span class="md-tag">guide</span>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<span class="md-tag">operator</span>
|
||
|
||
|
||
</nav>
|
||
|
||
|
||
|
||
<a href="https://gitea.bnkops.com/admin/changemaker.lite/src/branch/main/mkdocs/docs/docs/deployment/index.md" title="Edit this page" class="md-content__button md-icon" rel="edit">
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10 20H6V4h7v5h5v3.1l2-2V8l-6-6H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h4zm10.2-7c.1 0 .3.1.4.2l1.3 1.3c.2.2.2.6 0 .8l-1 1-2.1-2.1 1-1c.1-.1.2-.2.4-.2m0 3.9L14.1 23H12v-2.1l6.1-6.1z"/></svg>
|
||
</a>
|
||
|
||
|
||
|
||
|
||
|
||
<a href="https://gitea.bnkops.com/admin/changemaker.lite/src/branch/main/mkdocs/docs/docs/deployment/index.md" title="View source of this page" class="md-content__button md-icon">
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17 18c.56 0 1 .44 1 1s-.44 1-1 1-1-.44-1-1 .44-1 1-1m0-3c-2.73 0-5.06 1.66-6 4 .94 2.34 3.27 4 6 4s5.06-1.66 6-4c-.94-2.34-3.27-4-6-4m0 6.5a2.5 2.5 0 0 1-2.5-2.5 2.5 2.5 0 0 1 2.5-2.5 2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1-2.5 2.5M9.27 20H6V4h7v5h5v4.07c.7.08 1.36.25 2 .49V8l-6-6H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h4.5a8.2 8.2 0 0 1-1.23-2"/></svg>
|
||
</a>
|
||
|
||
|
||
|
||
<h1 id="deployment">Deployment<a class="headerlink" href="#deployment" title="Permanent link">¶</a></h1>
|
||
<p>This guide covers how to take Changemaker Lite from a local development setup to a publicly accessible production deployment. The main decision is <strong>how to expose your services to the internet</strong>.</p>
|
||
<h2 id="architecture-overview">Architecture Overview<a class="headerlink" href="#architecture-overview" title="Permanent link">¶</a></h2>
|
||
<p>Regardless of which exposure method you choose, the internal architecture is the same:</p>
|
||
<div class="language-text highlight"><pre><span></span><code><span id="__span-0-1"><a id="__codelineno-0-1" name="__codelineno-0-1" href="#__codelineno-0-1"></a>Internet → [Your exposure method] → Nginx (port 80) → Backend Services
|
||
</span></code></pre></div>
|
||
<p>Nginx handles all subdomain routing internally. Every service is accessed through nginx on port 80, which proxies to the correct container based on the <code>Host</code> header.</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Subdomain</th>
|
||
<th>Service</th>
|
||
<th>Container Port</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td><code>app.DOMAIN</code></td>
|
||
<td>Admin GUI + public pages</td>
|
||
<td>3000</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>api.DOMAIN</code></td>
|
||
<td>Express API</td>
|
||
<td>4000</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>media.DOMAIN</code></td>
|
||
<td>Fastify Media API</td>
|
||
<td>4100</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>DOMAIN</code> (root)</td>
|
||
<td>MkDocs documentation site</td>
|
||
<td>4004</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>db.DOMAIN</code></td>
|
||
<td>NocoDB</td>
|
||
<td>8091</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>docs.DOMAIN</code></td>
|
||
<td>MkDocs live preview</td>
|
||
<td>4003</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>code.DOMAIN</code></td>
|
||
<td>Code Server</td>
|
||
<td>8888</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>git.DOMAIN</code></td>
|
||
<td>Gitea</td>
|
||
<td>3030</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>n8n.DOMAIN</code></td>
|
||
<td>Workflow automation</td>
|
||
<td>5678</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>home.DOMAIN</code></td>
|
||
<td>Homepage dashboard</td>
|
||
<td>3010</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>listmonk.DOMAIN</code></td>
|
||
<td>Newsletter manager</td>
|
||
<td>9001</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>mail.DOMAIN</code></td>
|
||
<td>MailHog (dev email)</td>
|
||
<td>8025</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>qr.DOMAIN</code></td>
|
||
<td>Mini QR generator</td>
|
||
<td>8089</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>draw.DOMAIN</code></td>
|
||
<td>Excalidraw whiteboard</td>
|
||
<td>8090</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>vault.DOMAIN</code></td>
|
||
<td>Vaultwarden password manager</td>
|
||
<td>8445</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>chat.DOMAIN</code></td>
|
||
<td>Rocket.Chat team chat</td>
|
||
<td>—</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>events.DOMAIN</code></td>
|
||
<td>Gancio event management</td>
|
||
<td>8092</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>grafana.DOMAIN</code></td>
|
||
<td>Monitoring dashboards</td>
|
||
<td>3005</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<hr />
|
||
<h2 id="exposure-methods">Exposure Methods<a class="headerlink" href="#exposure-methods" title="Permanent link">¶</a></h2>
|
||
<h3 id="pangolin">Option 1: Pangolin + Newt Tunnel (Recommended)<a class="headerlink" href="#pangolin" title="Permanent link">¶</a></h3>
|
||
<div class="admonition tip">
|
||
<p class="admonition-title">Admin GUI: Tunnel Management Page</p>
|
||
<p>The admin dashboard includes a dedicated <strong>Tunnel Management</strong> page at <strong>Admin → Settings → Tunnel</strong>. This page provides:</p>
|
||
<ul>
|
||
<li><strong>Live status</strong> of the Pangolin connection and Newt container health</li>
|
||
<li><strong>Step-by-step setup instructions</strong> if credentials aren't configured yet</li>
|
||
<li><strong>Full resource table</strong> listing every service, its domain, and target — useful as a reference when creating resources in the Pangolin dashboard</li>
|
||
<li><strong>API-based site creation</strong> as an alternative to the Pangolin dashboard UI</li>
|
||
<li><strong>Restart Newt</strong> button for quick container restarts without the terminal</li>
|
||
</ul>
|
||
<p>If you're unsure about any step above, the Tunnel page walks you through the same process interactively.</p>
|
||
</div>
|
||
<p><a href="https://github.com/fosrl/pangolin">Pangolin</a> is a self-hosted tunnel server. The <strong>Newt</strong> client container runs alongside your stack and establishes an outbound connection to your Pangolin server, which then routes public traffic back through the tunnel. No port forwarding or static IP required.</p>
|
||
<p><strong>Advantages:</strong></p>
|
||
<ul>
|
||
<li>No port forwarding needed on your router/firewall</li>
|
||
<li>Works behind CGNAT, double NAT, or restrictive networks</li>
|
||
<li>SSL/TLS handled by the Pangolin server</li>
|
||
<li>Self-hosted — you control the tunnel infrastructure</li>
|
||
<li>Built-in access control (optional per-resource authentication)</li>
|
||
</ul>
|
||
<p><strong>Requirements:</strong></p>
|
||
<ul>
|
||
<li>A Pangolin server (self-hosted on a VPS with a public IP)</li>
|
||
<li>A domain with DNS pointing to the Pangolin server</li>
|
||
<li>Pangolin API key and organization ID</li>
|
||
</ul>
|
||
<h4 id="step-1-configure-pangolin-credentials">Step 1: Configure Pangolin Credentials<a class="headerlink" href="#step-1-configure-pangolin-credentials" title="Permanent link">¶</a></h4>
|
||
<p>If you used <code>config.sh</code>, you may have already set these. Otherwise, add to your <code>.env</code>:</p>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1" href="#__codelineno-1-1"></a><span class="nv">PANGOLIN_API_URL</span><span class="o">=</span>https://api.your-pangolin-server.org/v1
|
||
</span><span id="__span-1-2"><a id="__codelineno-1-2" name="__codelineno-1-2" href="#__codelineno-1-2"></a><span class="nv">PANGOLIN_API_KEY</span><span class="o">=</span>your_api_key_here
|
||
</span><span id="__span-1-3"><a id="__codelineno-1-3" name="__codelineno-1-3" href="#__codelineno-1-3"></a><span class="nv">PANGOLIN_ORG_ID</span><span class="o">=</span>your_org_id
|
||
</span></code></pre></div>
|
||
<h4 id="step-2-create-a-site-in-pangolin">Step 2: Create a Site in Pangolin<a class="headerlink" href="#step-2-create-a-site-in-pangolin" title="Permanent link">¶</a></h4>
|
||
<p>Log in to your Pangolin dashboard and create a new site:</p>
|
||
<ol>
|
||
<li>Navigate to <strong>Sites</strong> → <strong>Create New Site</strong></li>
|
||
<li>Choose type: <strong>Newt</strong></li>
|
||
<li>Enter a name (e.g., <code>changemaker-yourdomain.org</code>)</li>
|
||
<li>Choose a subnet (e.g., <code>100.90.128.3/24</code>)</li>
|
||
<li>Select an exit node (if applicable)</li>
|
||
<li>Click <strong>Create Site</strong></li>
|
||
<li><strong>Copy the credentials</strong> — you'll need the Site ID, Newt ID, and Newt Secret</li>
|
||
</ol>
|
||
<div class="admonition warning">
|
||
<p class="admonition-title">Save the credentials</p>
|
||
<p>The Newt Secret is only shown once during site creation. Copy it immediately.</p>
|
||
</div>
|
||
<h4 id="step-3-update-env-with-site-credentials">Step 3: Update <code>.env</code> with Site Credentials<a class="headerlink" href="#step-3-update-env-with-site-credentials" title="Permanent link">¶</a></h4>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-2-1"><a id="__codelineno-2-1" name="__codelineno-2-1" href="#__codelineno-2-1"></a><span class="nv">PANGOLIN_SITE_ID</span><span class="o">=</span>your_site_id
|
||
</span><span id="__span-2-2"><a id="__codelineno-2-2" name="__codelineno-2-2" href="#__codelineno-2-2"></a><span class="nv">PANGOLIN_ENDPOINT</span><span class="o">=</span>https://your-pangolin-server.org
|
||
</span><span id="__span-2-3"><a id="__codelineno-2-3" name="__codelineno-2-3" href="#__codelineno-2-3"></a><span class="nv">PANGOLIN_NEWT_ID</span><span class="o">=</span>your_newt_id
|
||
</span><span id="__span-2-4"><a id="__codelineno-2-4" name="__codelineno-2-4" href="#__codelineno-2-4"></a><span class="nv">PANGOLIN_NEWT_SECRET</span><span class="o">=</span>your_newt_secret
|
||
</span></code></pre></div>
|
||
<h4 id="step-4-start-the-newt-container">Step 4: Start the Newt Container<a class="headerlink" href="#step-4-start-the-newt-container" title="Permanent link">¶</a></h4>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-3-1"><a id="__codelineno-3-1" name="__codelineno-3-1" href="#__codelineno-3-1"></a>docker<span class="w"> </span>compose<span class="w"> </span>up<span class="w"> </span>-d<span class="w"> </span>newt
|
||
</span></code></pre></div>
|
||
<p>The Newt container connects to nginx (its only dependency) and establishes the tunnel:</p>
|
||
<div class="language-yaml highlight"><pre><span></span><code><span id="__span-4-1"><a id="__codelineno-4-1" name="__codelineno-4-1" href="#__codelineno-4-1"></a><span class="c1"># From docker-compose.yml</span>
|
||
</span><span id="__span-4-2"><a id="__codelineno-4-2" name="__codelineno-4-2" href="#__codelineno-4-2"></a><span class="nt">newt</span><span class="p">:</span>
|
||
</span><span id="__span-4-3"><a id="__codelineno-4-3" name="__codelineno-4-3" href="#__codelineno-4-3"></a><span class="w"> </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">fosrl/newt</span>
|
||
</span><span id="__span-4-4"><a id="__codelineno-4-4" name="__codelineno-4-4" href="#__codelineno-4-4"></a><span class="w"> </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">newt-changemaker</span>
|
||
</span><span id="__span-4-5"><a id="__codelineno-4-5" name="__codelineno-4-5" href="#__codelineno-4-5"></a><span class="w"> </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">unless-stopped</span>
|
||
</span><span id="__span-4-6"><a id="__codelineno-4-6" name="__codelineno-4-6" href="#__codelineno-4-6"></a><span class="w"> </span><span class="nt">environment</span><span class="p">:</span>
|
||
</span><span id="__span-4-7"><a id="__codelineno-4-7" name="__codelineno-4-7" href="#__codelineno-4-7"></a><span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">PANGOLIN_ENDPOINT=${PANGOLIN_ENDPOINT}</span>
|
||
</span><span id="__span-4-8"><a id="__codelineno-4-8" name="__codelineno-4-8" href="#__codelineno-4-8"></a><span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">NEWT_ID=${PANGOLIN_NEWT_ID}</span>
|
||
</span><span id="__span-4-9"><a id="__codelineno-4-9" name="__codelineno-4-9" href="#__codelineno-4-9"></a><span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">NEWT_SECRET=${PANGOLIN_NEWT_SECRET}</span>
|
||
</span><span id="__span-4-10"><a id="__codelineno-4-10" name="__codelineno-4-10" href="#__codelineno-4-10"></a><span class="w"> </span><span class="nt">depends_on</span><span class="p">:</span>
|
||
</span><span id="__span-4-11"><a id="__codelineno-4-11" name="__codelineno-4-11" href="#__codelineno-4-11"></a><span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">nginx</span>
|
||
</span></code></pre></div>
|
||
<p>Verify the connection:</p>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-5-1"><a id="__codelineno-5-1" name="__codelineno-5-1" href="#__codelineno-5-1"></a>docker<span class="w"> </span>compose<span class="w"> </span>logs<span class="w"> </span>newt<span class="w"> </span>--tail<span class="w"> </span><span class="m">20</span>
|
||
</span></code></pre></div>
|
||
<p>You should see a successful connection message.</p>
|
||
<h4 id="step-5-create-public-http-resources">Step 5: Create Public HTTP Resources<a class="headerlink" href="#step-5-create-public-http-resources" title="Permanent link">¶</a></h4>
|
||
<p>In the Pangolin dashboard, create an HTTP resource for each service you want exposed. All resources point to <code>nginx:80</code> — nginx handles the routing internally.</p>
|
||
<p><strong>Required resources</strong> (minimum for a working deployment):</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Resource Name</th>
|
||
<th>Domain</th>
|
||
<th>Target</th>
|
||
<th>Auth</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>Admin GUI</td>
|
||
<td><code>app.yourdomain.org</code></td>
|
||
<td><code>nginx:80</code></td>
|
||
<td>Not Protected</td>
|
||
</tr>
|
||
<tr>
|
||
<td>API Server</td>
|
||
<td><code>api.yourdomain.org</code></td>
|
||
<td><code>nginx:80</code></td>
|
||
<td>Not Protected</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Public Site</td>
|
||
<td><code>yourdomain.org</code></td>
|
||
<td><code>nginx:80</code></td>
|
||
<td>Not Protected</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p><strong>Optional resources</strong> (add as needed):</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Resource Name</th>
|
||
<th>Domain</th>
|
||
<th>Target</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>Media API</td>
|
||
<td><code>media.yourdomain.org</code></td>
|
||
<td><code>nginx:80</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>NocoDB</td>
|
||
<td><code>db.yourdomain.org</code></td>
|
||
<td><code>nginx:80</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Documentation</td>
|
||
<td><code>docs.yourdomain.org</code></td>
|
||
<td><code>nginx:80</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Code Server</td>
|
||
<td><code>code.yourdomain.org</code></td>
|
||
<td><code>nginx:80</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Gitea</td>
|
||
<td><code>git.yourdomain.org</code></td>
|
||
<td><code>nginx:80</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Grafana</td>
|
||
<td><code>grafana.yourdomain.org</code></td>
|
||
<td><code>nginx:80</code></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div class="admonition danger">
|
||
<p class="admonition-title">Set resources to Not Protected</p>
|
||
<p>By default, Pangolin may enable authentication on new resources. This causes 302 redirects to the Pangolin login page instead of reaching your services. Set each resource to <strong>Not Protected</strong> (public access) unless you intentionally want Pangolin SSO in front of it.</p>
|
||
</div>
|
||
<h4 id="step-6-update-cors-for-production">Step 6: Update CORS for Production<a class="headerlink" href="#step-6-update-cors-for-production" title="Permanent link">¶</a></h4>
|
||
<p>Add your production domain to <code>CORS_ORIGINS</code> in <code>.env</code>:</p>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-6-1"><a id="__codelineno-6-1" name="__codelineno-6-1" href="#__codelineno-6-1"></a><span class="nv">CORS_ORIGINS</span><span class="o">=</span>https://app.yourdomain.org,http://localhost:3000,http://localhost
|
||
</span></code></pre></div>
|
||
<p>Then restart the API:</p>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-7-1"><a id="__codelineno-7-1" name="__codelineno-7-1" href="#__codelineno-7-1"></a>docker<span class="w"> </span>compose<span class="w"> </span>restart<span class="w"> </span>api
|
||
</span></code></pre></div>
|
||
<h4 id="step-7-verify">Step 7: Verify<a class="headerlink" href="#step-7-verify" title="Permanent link">¶</a></h4>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-8-1"><a id="__codelineno-8-1" name="__codelineno-8-1" href="#__codelineno-8-1"></a><span class="c1"># Should return JSON (not a 302 redirect)</span>
|
||
</span><span id="__span-8-2"><a id="__codelineno-8-2" name="__codelineno-8-2" href="#__codelineno-8-2"></a>curl<span class="w"> </span>https://api.yourdomain.org/api/health
|
||
</span><span id="__span-8-3"><a id="__codelineno-8-3" name="__codelineno-8-3" href="#__codelineno-8-3"></a>
|
||
</span><span id="__span-8-4"><a id="__codelineno-8-4" name="__codelineno-8-4" href="#__codelineno-8-4"></a><span class="c1"># Admin GUI should load</span>
|
||
</span><span id="__span-8-5"><a id="__codelineno-8-5" name="__codelineno-8-5" href="#__codelineno-8-5"></a>curl<span class="w"> </span>-I<span class="w"> </span>https://app.yourdomain.org
|
||
</span></code></pre></div>
|
||
<hr />
|
||
<h3 id="cloudflare">Option 2: Cloudflare Tunnel<a class="headerlink" href="#cloudflare" title="Permanent link">¶</a></h3>
|
||
<p>Cloudflare Tunnel (<code>cloudflared</code>) provides a similar zero-trust tunnel approach using Cloudflare's network. No port forwarding needed, and you get Cloudflare's CDN and DDoS protection.</p>
|
||
<p><strong>Advantages:</strong></p>
|
||
<ul>
|
||
<li>Free tier available</li>
|
||
<li>Built-in CDN and DDoS protection</li>
|
||
<li>No port forwarding needed</li>
|
||
<li>Managed SSL certificates</li>
|
||
</ul>
|
||
<p><strong>Disadvantages:</strong></p>
|
||
<ul>
|
||
<li>Proprietary service (not self-hosted)</li>
|
||
<li>Cloudflare sees all traffic (no end-to-end encryption to your origin)</li>
|
||
<li>Subject to Cloudflare's Terms of Service</li>
|
||
</ul>
|
||
<h4 id="setup">Setup<a class="headerlink" href="#setup" title="Permanent link">¶</a></h4>
|
||
<ol>
|
||
<li>
|
||
<p><strong>Create a Cloudflare Tunnel</strong> in the <a href="https://one.dash.cloudflare.com/">Zero Trust dashboard</a></p>
|
||
</li>
|
||
<li>
|
||
<p><strong>Add a <code>cloudflared</code> service</strong> to your <code>docker-compose.yml</code>:</p>
|
||
<div class="language-yaml highlight"><pre><span></span><code><span id="__span-9-1"><a id="__codelineno-9-1" name="__codelineno-9-1" href="#__codelineno-9-1"></a><span class="nt">cloudflared</span><span class="p">:</span>
|
||
</span><span id="__span-9-2"><a id="__codelineno-9-2" name="__codelineno-9-2" href="#__codelineno-9-2"></a><span class="w"> </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">cloudflare/cloudflared:latest</span>
|
||
</span><span id="__span-9-3"><a id="__codelineno-9-3" name="__codelineno-9-3" href="#__codelineno-9-3"></a><span class="w"> </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">cloudflared-changemaker</span>
|
||
</span><span id="__span-9-4"><a id="__codelineno-9-4" name="__codelineno-9-4" href="#__codelineno-9-4"></a><span class="w"> </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">unless-stopped</span>
|
||
</span><span id="__span-9-5"><a id="__codelineno-9-5" name="__codelineno-9-5" href="#__codelineno-9-5"></a><span class="w"> </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">tunnel run</span>
|
||
</span><span id="__span-9-6"><a id="__codelineno-9-6" name="__codelineno-9-6" href="#__codelineno-9-6"></a><span class="w"> </span><span class="nt">environment</span><span class="p">:</span>
|
||
</span><span id="__span-9-7"><a id="__codelineno-9-7" name="__codelineno-9-7" href="#__codelineno-9-7"></a><span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}</span>
|
||
</span><span id="__span-9-8"><a id="__codelineno-9-8" name="__codelineno-9-8" href="#__codelineno-9-8"></a><span class="w"> </span><span class="nt">depends_on</span><span class="p">:</span>
|
||
</span><span id="__span-9-9"><a id="__codelineno-9-9" name="__codelineno-9-9" href="#__codelineno-9-9"></a><span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">nginx</span>
|
||
</span><span id="__span-9-10"><a id="__codelineno-9-10" name="__codelineno-9-10" href="#__codelineno-9-10"></a><span class="w"> </span><span class="nt">networks</span><span class="p">:</span>
|
||
</span><span id="__span-9-11"><a id="__codelineno-9-11" name="__codelineno-9-11" href="#__codelineno-9-11"></a><span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">changemaker-lite</span>
|
||
</span></code></pre></div>
|
||
</li>
|
||
<li>
|
||
<p><strong>Add your tunnel token</strong> to <code>.env</code>:</p>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-10-1"><a id="__codelineno-10-1" name="__codelineno-10-1" href="#__codelineno-10-1"></a><span class="nv">CLOUDFLARE_TUNNEL_TOKEN</span><span class="o">=</span>your_tunnel_token_here
|
||
</span></code></pre></div>
|
||
</li>
|
||
<li>
|
||
<p><strong>Configure public hostnames</strong> in the Cloudflare dashboard, all pointing to <code>http://nginx:80</code>:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Hostname</th>
|
||
<th>Service</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td><code>app.yourdomain.org</code></td>
|
||
<td><code>http://nginx:80</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>api.yourdomain.org</code></td>
|
||
<td><code>http://nginx:80</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>yourdomain.org</code></td>
|
||
<td><code>http://nginx:80</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td><em>(add more as needed)</em></td>
|
||
<td><code>http://nginx:80</code></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</li>
|
||
<li>
|
||
<p><strong>Start the tunnel:</strong></p>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-11-1"><a id="__codelineno-11-1" name="__codelineno-11-1" href="#__codelineno-11-1"></a>docker<span class="w"> </span>compose<span class="w"> </span>up<span class="w"> </span>-d<span class="w"> </span>cloudflared
|
||
</span></code></pre></div>
|
||
</li>
|
||
</ol>
|
||
<div class="admonition note">
|
||
<p class="admonition-title">Note</p>
|
||
<p>The <code>cloudflared</code> service is not included in the default <code>docker-compose.yml</code>. Add it manually if you choose this method. The Newt service can be removed or left stopped.</p>
|
||
</div>
|
||
<hr />
|
||
<h3 id="direct">Option 3: Direct DNS + Reverse Proxy<a class="headerlink" href="#direct" title="Permanent link">¶</a></h3>
|
||
<p>If your server has a public IP address (e.g., a VPS or dedicated server), you can point DNS directly to it and use nginx with SSL certificates.</p>
|
||
<p><strong>Advantages:</strong></p>
|
||
<ul>
|
||
<li>No tunnel overhead or third-party dependency</li>
|
||
<li>Full control over the network path</li>
|
||
<li>Lowest latency</li>
|
||
</ul>
|
||
<p><strong>Disadvantages:</strong></p>
|
||
<ul>
|
||
<li>Requires a public IP and open ports (80, 443)</li>
|
||
<li>You manage SSL certificates yourself</li>
|
||
<li>Server IP is exposed</li>
|
||
</ul>
|
||
<h4 id="setup_1">Setup<a class="headerlink" href="#setup_1" title="Permanent link">¶</a></h4>
|
||
<ol>
|
||
<li>
|
||
<p><strong>Point DNS</strong> for your domain and all subdomains to your server's IP:</p>
|
||
<div class="language-text highlight"><pre><span></span><code><span id="__span-12-1"><a id="__codelineno-12-1" name="__codelineno-12-1" href="#__codelineno-12-1"></a>A yourdomain.org → YOUR_SERVER_IP
|
||
</span><span id="__span-12-2"><a id="__codelineno-12-2" name="__codelineno-12-2" href="#__codelineno-12-2"></a>A *.yourdomain.org → YOUR_SERVER_IP
|
||
</span></code></pre></div>
|
||
<p>Or use individual A records for each subdomain if your DNS provider doesn't support wildcards.</p>
|
||
</li>
|
||
<li>
|
||
<p><strong>Open ports</strong> 80 and 443 on your server's firewall.</p>
|
||
</li>
|
||
<li>
|
||
<p><strong>Install Certbot</strong> (or another ACME client) for SSL certificates:</p>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-13-1"><a id="__codelineno-13-1" name="__codelineno-13-1" href="#__codelineno-13-1"></a><span class="c1"># Ubuntu/Debian</span>
|
||
</span><span id="__span-13-2"><a id="__codelineno-13-2" name="__codelineno-13-2" href="#__codelineno-13-2"></a>sudo<span class="w"> </span>apt<span class="w"> </span>install<span class="w"> </span>certbot
|
||
</span><span id="__span-13-3"><a id="__codelineno-13-3" name="__codelineno-13-3" href="#__codelineno-13-3"></a>
|
||
</span><span id="__span-13-4"><a id="__codelineno-13-4" name="__codelineno-13-4" href="#__codelineno-13-4"></a><span class="c1"># Get a wildcard certificate with DNS challenge</span>
|
||
</span><span id="__span-13-5"><a id="__codelineno-13-5" name="__codelineno-13-5" href="#__codelineno-13-5"></a>sudo<span class="w"> </span>certbot<span class="w"> </span>certonly<span class="w"> </span>--manual<span class="w"> </span>--preferred-challenges<span class="w"> </span>dns<span class="w"> </span><span class="se">\</span>
|
||
</span><span id="__span-13-6"><a id="__codelineno-13-6" name="__codelineno-13-6" href="#__codelineno-13-6"></a><span class="w"> </span>-d<span class="w"> </span>yourdomain.org<span class="w"> </span>-d<span class="w"> </span><span class="s1">'*.yourdomain.org'</span>
|
||
</span></code></pre></div>
|
||
<p>Alternatively, use the <a href="https://hub.docker.com/r/certbot/certbot/">Certbot Docker image</a> or a Let's Encrypt companion container.</p>
|
||
</li>
|
||
<li>
|
||
<p><strong>Update nginx</strong> to listen on 443 with your certificates. Add an SSL server block to <code>nginx/conf.d/ssl.conf</code>:</p>
|
||
<div class="language-nginx highlight"><pre><span></span><code><span id="__span-14-1"><a id="__codelineno-14-1" name="__codelineno-14-1" href="#__codelineno-14-1"></a><span class="k">server</span><span class="w"> </span><span class="p">{</span>
|
||
</span><span id="__span-14-2"><a id="__codelineno-14-2" name="__codelineno-14-2" href="#__codelineno-14-2"></a><span class="w"> </span><span class="kn">listen</span><span class="w"> </span><span class="mi">443</span><span class="w"> </span><span class="s">ssl</span><span class="p">;</span>
|
||
</span><span id="__span-14-3"><a id="__codelineno-14-3" name="__codelineno-14-3" href="#__codelineno-14-3"></a><span class="w"> </span><span class="kn">server_name</span><span class="w"> </span><span class="s">app.yourdomain.org</span><span class="p">;</span>
|
||
</span><span id="__span-14-4"><a id="__codelineno-14-4" name="__codelineno-14-4" href="#__codelineno-14-4"></a>
|
||
</span><span id="__span-14-5"><a id="__codelineno-14-5" name="__codelineno-14-5" href="#__codelineno-14-5"></a><span class="w"> </span><span class="kn">ssl_certificate</span><span class="w"> </span><span class="s">/etc/nginx/ssl/fullchain.pem</span><span class="p">;</span>
|
||
</span><span id="__span-14-6"><a id="__codelineno-14-6" name="__codelineno-14-6" href="#__codelineno-14-6"></a><span class="w"> </span><span class="kn">ssl_certificate_key</span><span class="w"> </span><span class="s">/etc/nginx/ssl/privkey.pem</span><span class="p">;</span>
|
||
</span><span id="__span-14-7"><a id="__codelineno-14-7" name="__codelineno-14-7" href="#__codelineno-14-7"></a>
|
||
</span><span id="__span-14-8"><a id="__codelineno-14-8" name="__codelineno-14-8" href="#__codelineno-14-8"></a><span class="w"> </span><span class="kn">location</span><span class="w"> </span><span class="s">/</span><span class="w"> </span><span class="p">{</span>
|
||
</span><span id="__span-14-9"><a id="__codelineno-14-9" name="__codelineno-14-9" href="#__codelineno-14-9"></a><span class="w"> </span><span class="kn">proxy_pass</span><span class="w"> </span><span class="s">http://changemaker-v2-admin:3000</span><span class="p">;</span>
|
||
</span><span id="__span-14-10"><a id="__codelineno-14-10" name="__codelineno-14-10" href="#__codelineno-14-10"></a><span class="w"> </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Host</span><span class="w"> </span><span class="nv">$host</span><span class="p">;</span>
|
||
</span><span id="__span-14-11"><a id="__codelineno-14-11" name="__codelineno-14-11" href="#__codelineno-14-11"></a><span class="w"> </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">X-Real-IP</span><span class="w"> </span><span class="nv">$remote_addr</span><span class="p">;</span>
|
||
</span><span id="__span-14-12"><a id="__codelineno-14-12" name="__codelineno-14-12" href="#__codelineno-14-12"></a><span class="w"> </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">X-Forwarded-For</span><span class="w"> </span><span class="nv">$proxy_add_x_forwarded_for</span><span class="p">;</span>
|
||
</span><span id="__span-14-13"><a id="__codelineno-14-13" name="__codelineno-14-13" href="#__codelineno-14-13"></a><span class="w"> </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">X-Forwarded-Proto</span><span class="w"> </span><span class="nv">$scheme</span><span class="p">;</span>
|
||
</span><span id="__span-14-14"><a id="__codelineno-14-14" name="__codelineno-14-14" href="#__codelineno-14-14"></a><span class="w"> </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Upgrade</span><span class="w"> </span><span class="nv">$http_upgrade</span><span class="p">;</span>
|
||
</span><span id="__span-14-15"><a id="__codelineno-14-15" name="__codelineno-14-15" href="#__codelineno-14-15"></a><span class="w"> </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Connection</span><span class="w"> </span><span class="s">"upgrade"</span><span class="p">;</span>
|
||
</span><span id="__span-14-16"><a id="__codelineno-14-16" name="__codelineno-14-16" href="#__codelineno-14-16"></a><span class="w"> </span><span class="p">}</span>
|
||
</span><span id="__span-14-17"><a id="__codelineno-14-17" name="__codelineno-14-17" href="#__codelineno-14-17"></a><span class="p">}</span>
|
||
</span><span id="__span-14-18"><a id="__codelineno-14-18" name="__codelineno-14-18" href="#__codelineno-14-18"></a>
|
||
</span><span id="__span-14-19"><a id="__codelineno-14-19" name="__codelineno-14-19" href="#__codelineno-14-19"></a><span class="c1"># Repeat for api.yourdomain.org, media.yourdomain.org, etc.</span>
|
||
</span><span id="__span-14-20"><a id="__codelineno-14-20" name="__codelineno-14-20" href="#__codelineno-14-20"></a><span class="c1"># Or use a single server block with $host matching</span>
|
||
</span></code></pre></div>
|
||
</li>
|
||
<li>
|
||
<p><strong>Mount certificates</strong> into the nginx container via <code>docker-compose.yml</code>:</p>
|
||
<div class="language-yaml highlight"><pre><span></span><code><span id="__span-15-1"><a id="__codelineno-15-1" name="__codelineno-15-1" href="#__codelineno-15-1"></a><span class="nt">nginx</span><span class="p">:</span>
|
||
</span><span id="__span-15-2"><a id="__codelineno-15-2" name="__codelineno-15-2" href="#__codelineno-15-2"></a><span class="w"> </span><span class="nt">volumes</span><span class="p">:</span>
|
||
</span><span id="__span-15-3"><a id="__codelineno-15-3" name="__codelineno-15-3" href="#__codelineno-15-3"></a><span class="w"> </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">/etc/letsencrypt/live/yourdomain.org:/etc/nginx/ssl:ro</span>
|
||
</span></code></pre></div>
|
||
</li>
|
||
<li>
|
||
<p><strong>Set up auto-renewal</strong> with a cron job or systemd timer:</p>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-16-1"><a id="__codelineno-16-1" name="__codelineno-16-1" href="#__codelineno-16-1"></a><span class="m">0</span><span class="w"> </span><span class="m">3</span><span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span>certbot<span class="w"> </span>renew<span class="w"> </span>--quiet<span class="w"> </span><span class="o">&&</span><span class="w"> </span>docker<span class="w"> </span>compose<span class="w"> </span>restart<span class="w"> </span>nginx
|
||
</span></code></pre></div>
|
||
</li>
|
||
</ol>
|
||
<div class="admonition tip">
|
||
<p class="admonition-title">Traefik alternative</p>
|
||
<p>If you prefer automatic SSL and don't want to manage nginx SSL config manually, consider replacing nginx with <a href="https://traefik.io/">Traefik</a>. Traefik can auto-discover Docker containers and provision Let's Encrypt certificates automatically. This would require adapting the container labels and removing the nginx service.</p>
|
||
</div>
|
||
<hr />
|
||
<h3 id="tailscale">Option 4: Tailscale / WireGuard (Private Access)<a class="headerlink" href="#tailscale" title="Permanent link">¶</a></h3>
|
||
<p>For deployments that should only be accessible to specific people (not the general public), a mesh VPN like Tailscale or plain WireGuard gives you private networking without exposing anything to the internet.</p>
|
||
<p><strong>Use cases:</strong></p>
|
||
<ul>
|
||
<li>Internal team deployments</li>
|
||
<li>Development/staging servers</li>
|
||
<li>Access from mobile devices without public exposure</li>
|
||
</ul>
|
||
<h4 id="tailscale-setup">Tailscale Setup<a class="headerlink" href="#tailscale-setup" title="Permanent link">¶</a></h4>
|
||
<ol>
|
||
<li>Install Tailscale on your server and client devices</li>
|
||
<li>Access services via Tailscale IP (e.g., <code>http://100.x.x.x:3000</code>)</li>
|
||
<li>Optionally use <a href="https://tailscale.com/kb/1223/funnel/">Tailscale Funnel</a> to selectively expose specific services publicly</li>
|
||
</ol>
|
||
<h4 id="wireguard-setup">WireGuard Setup<a class="headerlink" href="#wireguard-setup" title="Permanent link">¶</a></h4>
|
||
<ol>
|
||
<li>Set up a WireGuard server on your host</li>
|
||
<li>Connect client devices via WireGuard config</li>
|
||
<li>Access services via the WireGuard interface IP</li>
|
||
</ol>
|
||
<div class="admonition note">
|
||
<p class="admonition-title">Note</p>
|
||
<p>With private access methods, you may not need subdomain routing at all. Access services directly by port: <code>http://server-ip:3000</code> (admin), <code>http://server-ip:4000</code> (API), etc.</p>
|
||
</div>
|
||
<hr />
|
||
<h2 id="production-checklist">Production Checklist<a class="headerlink" href="#production-checklist" title="Permanent link">¶</a></h2>
|
||
<p>Before going live, verify each item:</p>
|
||
<h3 id="security">Security<a class="headerlink" href="#security" title="Permanent link">¶</a></h3>
|
||
<ul class="task-list">
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> All placeholder passwords changed (<code>grep -c "REQUIRED_STRONG" .env</code> should return <code>0</code>)</li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <code>NODE_ENV=production</code> set in <code>.env</code></li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <code>ENCRYPTION_KEY</code> set and differs from JWT secrets</li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <code>EMAIL_TEST_MODE=false</code> (unless you want MailHog in production)</li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <code>CORS_ORIGINS</code> includes your production domain</li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Admin password changed after first login</li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Redis password set (<code>REDIS_PASSWORD</code>)</li>
|
||
</ul>
|
||
<h3 id="networking">Networking<a class="headerlink" href="#networking" title="Permanent link">¶</a></h3>
|
||
<ul class="task-list">
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> DNS records configured for your domain and subdomains</li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> SSL/TLS working (tunnel handles this, or manual certs)</li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> All Pangolin resources set to "Not Protected" (if using Pangolin)</li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> <code>curl https://api.yourdomain.org/api/health</code> returns JSON</li>
|
||
</ul>
|
||
<h3 id="services">Services<a class="headerlink" href="#services" title="Permanent link">¶</a></h3>
|
||
<ul class="task-list">
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Core services running: <code>docker compose ps</code> shows <code>api</code>, <code>admin</code>, <code>v2-postgres</code>, <code>redis</code>, <code>nginx</code> healthy</li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Database migrated: <code>docker compose exec api npx prisma migrate deploy</code></li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Database seeded: <code>docker compose exec api npx prisma db seed</code></li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Admin GUI accessible at <code>https://app.yourdomain.org</code></li>
|
||
</ul>
|
||
<h3 id="backups">Backups<a class="headerlink" href="#backups" title="Permanent link">¶</a></h3>
|
||
<ul class="task-list">
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Backup script tested: <code>./scripts/backup.sh</code></li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Backup cron job configured (see <a href="#backups">Backups</a> below)</li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Restore procedure tested at least once</li>
|
||
</ul>
|
||
<h3 id="monitoring-optional">Monitoring (Optional)<a class="headerlink" href="#monitoring-optional" title="Permanent link">¶</a></h3>
|
||
<ul class="task-list">
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Monitoring stack started: <code>docker compose --profile monitoring up -d</code></li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Grafana accessible and dashboards loading</li>
|
||
<li class="task-list-item"><label class="task-list-control"><input type="checkbox" disabled/><span class="task-list-indicator"></span></label> Alert rules configured in Alertmanager</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="backups_1">Backups<a class="headerlink" href="#backups_1" title="Permanent link">¶</a></h2>
|
||
<p>The included backup script dumps PostgreSQL databases, archives uploads, and optionally uploads to S3.</p>
|
||
<h3 id="running-a-backup">Running a Backup<a class="headerlink" href="#running-a-backup" title="Permanent link">¶</a></h3>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-17-1"><a id="__codelineno-17-1" name="__codelineno-17-1" href="#__codelineno-17-1"></a>./scripts/backup.sh
|
||
</span></code></pre></div>
|
||
<p>This creates a timestamped directory under <code>./backups/</code> containing:</p>
|
||
<ul>
|
||
<li><code>changemaker_v2.sql.gz</code> — Main PostgreSQL dump (compressed)</li>
|
||
<li><code>listmonk.sql.gz</code> — Listmonk database dump (if running)</li>
|
||
<li><code>uploads.tar.gz</code> — Media uploads archive</li>
|
||
<li><code>manifest.json</code> — Backup metadata</li>
|
||
</ul>
|
||
<h3 id="options">Options<a class="headerlink" href="#options" title="Permanent link">¶</a></h3>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-18-1"><a id="__codelineno-18-1" name="__codelineno-18-1" href="#__codelineno-18-1"></a><span class="c1"># Upload to S3 (requires AWS CLI + S3_BUCKET env var)</span>
|
||
</span><span id="__span-18-2"><a id="__codelineno-18-2" name="__codelineno-18-2" href="#__codelineno-18-2"></a>./scripts/backup.sh<span class="w"> </span>--s3
|
||
</span><span id="__span-18-3"><a id="__codelineno-18-3" name="__codelineno-18-3" href="#__codelineno-18-3"></a>
|
||
</span><span id="__span-18-4"><a id="__codelineno-18-4" name="__codelineno-18-4" href="#__codelineno-18-4"></a><span class="c1"># Custom retention (delete local backups older than N days)</span>
|
||
</span><span id="__span-18-5"><a id="__codelineno-18-5" name="__codelineno-18-5" href="#__codelineno-18-5"></a>./scripts/backup.sh<span class="w"> </span>--retention<span class="w"> </span><span class="m">14</span>
|
||
</span></code></pre></div>
|
||
<h3 id="automated-backups">Automated Backups<a class="headerlink" href="#automated-backups" title="Permanent link">¶</a></h3>
|
||
<p>Add a cron job for daily backups:</p>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-19-1"><a id="__codelineno-19-1" name="__codelineno-19-1" href="#__codelineno-19-1"></a><span class="c1"># Edit crontab</span>
|
||
</span><span id="__span-19-2"><a id="__codelineno-19-2" name="__codelineno-19-2" href="#__codelineno-19-2"></a>crontab<span class="w"> </span>-e
|
||
</span><span id="__span-19-3"><a id="__codelineno-19-3" name="__codelineno-19-3" href="#__codelineno-19-3"></a>
|
||
</span><span id="__span-19-4"><a id="__codelineno-19-4" name="__codelineno-19-4" href="#__codelineno-19-4"></a><span class="c1"># Add daily backup at 3 AM</span>
|
||
</span><span id="__span-19-5"><a id="__codelineno-19-5" name="__codelineno-19-5" href="#__codelineno-19-5"></a><span class="m">0</span><span class="w"> </span><span class="m">3</span><span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span>/path/to/changemaker.lite/scripts/backup.sh<span class="w"> </span>>><span class="w"> </span>/var/log/changemaker-backup.log<span class="w"> </span><span class="m">2</span>><span class="p">&</span><span class="m">1</span>
|
||
</span><span id="__span-19-6"><a id="__codelineno-19-6" name="__codelineno-19-6" href="#__codelineno-19-6"></a>
|
||
</span><span id="__span-19-7"><a id="__codelineno-19-7" name="__codelineno-19-7" href="#__codelineno-19-7"></a><span class="c1"># With S3 upload</span>
|
||
</span><span id="__span-19-8"><a id="__codelineno-19-8" name="__codelineno-19-8" href="#__codelineno-19-8"></a><span class="m">0</span><span class="w"> </span><span class="m">3</span><span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span>*<span class="w"> </span>/path/to/changemaker.lite/scripts/backup.sh<span class="w"> </span>--s3<span class="w"> </span>>><span class="w"> </span>/var/log/changemaker-backup.log<span class="w"> </span><span class="m">2</span>><span class="p">&</span><span class="m">1</span>
|
||
</span></code></pre></div>
|
||
<h3 id="restore">Restore<a class="headerlink" href="#restore" title="Permanent link">¶</a></h3>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-20-1"><a id="__codelineno-20-1" name="__codelineno-20-1" href="#__codelineno-20-1"></a><span class="c1"># Restore main database</span>
|
||
</span><span id="__span-20-2"><a id="__codelineno-20-2" name="__codelineno-20-2" href="#__codelineno-20-2"></a>gunzip<span class="w"> </span>-c<span class="w"> </span>backups/changemaker-v2-backup-TIMESTAMP/changemaker_v2.sql.gz<span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
|
||
</span><span id="__span-20-3"><a id="__codelineno-20-3" name="__codelineno-20-3" href="#__codelineno-20-3"></a><span class="w"> </span>docker<span class="w"> </span>compose<span class="w"> </span><span class="nb">exec</span><span class="w"> </span>-T<span class="w"> </span>v2-postgres<span class="w"> </span>psql<span class="w"> </span>-U<span class="w"> </span>changemaker<span class="w"> </span>changemaker_v2
|
||
</span><span id="__span-20-4"><a id="__codelineno-20-4" name="__codelineno-20-4" href="#__codelineno-20-4"></a>
|
||
</span><span id="__span-20-5"><a id="__codelineno-20-5" name="__codelineno-20-5" href="#__codelineno-20-5"></a><span class="c1"># Restore Listmonk database</span>
|
||
</span><span id="__span-20-6"><a id="__codelineno-20-6" name="__codelineno-20-6" href="#__codelineno-20-6"></a>gunzip<span class="w"> </span>-c<span class="w"> </span>backups/changemaker-v2-backup-TIMESTAMP/listmonk.sql.gz<span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
|
||
</span><span id="__span-20-7"><a id="__codelineno-20-7" name="__codelineno-20-7" href="#__codelineno-20-7"></a><span class="w"> </span>docker<span class="w"> </span>compose<span class="w"> </span><span class="nb">exec</span><span class="w"> </span>-T<span class="w"> </span>listmonk-db<span class="w"> </span>psql<span class="w"> </span>-U<span class="w"> </span>listmonk<span class="w"> </span>listmonk
|
||
</span><span id="__span-20-8"><a id="__codelineno-20-8" name="__codelineno-20-8" href="#__codelineno-20-8"></a>
|
||
</span><span id="__span-20-9"><a id="__codelineno-20-9" name="__codelineno-20-9" href="#__codelineno-20-9"></a><span class="c1"># Restore uploads</span>
|
||
</span><span id="__span-20-10"><a id="__codelineno-20-10" name="__codelineno-20-10" href="#__codelineno-20-10"></a>tar<span class="w"> </span>xzf<span class="w"> </span>backups/changemaker-v2-backup-TIMESTAMP/uploads.tar.gz<span class="w"> </span>-C<span class="w"> </span>./
|
||
</span></code></pre></div>
|
||
<hr />
|
||
<h2 id="monitoring">Monitoring<a class="headerlink" href="#monitoring" title="Permanent link">¶</a></h2>
|
||
<p>The monitoring stack runs behind a Docker Compose profile and is not started by default.</p>
|
||
<h3 id="starting-the-monitoring-stack">Starting the Monitoring Stack<a class="headerlink" href="#starting-the-monitoring-stack" title="Permanent link">¶</a></h3>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-21-1"><a id="__codelineno-21-1" name="__codelineno-21-1" href="#__codelineno-21-1"></a>docker<span class="w"> </span>compose<span class="w"> </span>--profile<span class="w"> </span>monitoring<span class="w"> </span>up<span class="w"> </span>-d
|
||
</span></code></pre></div>
|
||
<p>This starts:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Service</th>
|
||
<th>Port</th>
|
||
<th>Purpose</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>Prometheus</td>
|
||
<td>9090</td>
|
||
<td>Metrics collection and queries</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Grafana</td>
|
||
<td>3005</td>
|
||
<td>Dashboards and visualization</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Alertmanager</td>
|
||
<td>9093</td>
|
||
<td>Alert routing and notifications</td>
|
||
</tr>
|
||
<tr>
|
||
<td>cAdvisor</td>
|
||
<td>8086</td>
|
||
<td>Container resource metrics</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Node Exporter</td>
|
||
<td>9100</td>
|
||
<td>Host system metrics</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Redis Exporter</td>
|
||
<td>9121</td>
|
||
<td>Redis metrics</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Gotify</td>
|
||
<td>8889</td>
|
||
<td>Push notifications</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<h3 id="pre-configured-dashboards">Pre-configured Dashboards<a class="headerlink" href="#pre-configured-dashboards" title="Permanent link">¶</a></h3>
|
||
<p>Grafana includes 3 auto-provisioned dashboards:</p>
|
||
<ol>
|
||
<li><strong>API Overview</strong> — HTTP request rates, latency, error rates, active sessions</li>
|
||
<li><strong>Infrastructure</strong> — Container CPU/memory, PostgreSQL connections, Redis memory</li>
|
||
<li><strong>Campaign Activity</strong> — Email queue size, campaign sends, response submissions</li>
|
||
</ol>
|
||
<h3 id="custom-metrics">Custom Metrics<a class="headerlink" href="#custom-metrics" title="Permanent link">¶</a></h3>
|
||
<p>The API exposes 12 custom Prometheus metrics with the <code>cm_</code> prefix:</p>
|
||
<ul>
|
||
<li><code>cm_api_uptime_seconds</code> — API uptime</li>
|
||
<li><code>cm_email_queue_size</code> — BullMQ pending emails</li>
|
||
<li><code>cm_active_canvass_sessions</code> — Active canvassing sessions</li>
|
||
<li><code>cm_locations_total</code> — Total locations in database</li>
|
||
<li>And more — see <code>api/src/utils/metrics.ts</code></li>
|
||
</ul>
|
||
<h3 id="alert-rules">Alert Rules<a class="headerlink" href="#alert-rules" title="Permanent link">¶</a></h3>
|
||
<p>Pre-configured alerts in <code>configs/prometheus/alerts.yml</code>:</p>
|
||
<ul>
|
||
<li>API down for more than 5 minutes</li>
|
||
<li>High error rate (>5% of requests returning 5xx)</li>
|
||
<li>Database connection failures</li>
|
||
<li>Redis connection failures</li>
|
||
<li>Email queue backlog</li>
|
||
<li>Disk space warnings</li>
|
||
</ul>
|
||
<hr />
|
||
<h2 id="upgrading">Upgrading<a class="headerlink" href="#upgrading" title="Permanent link">¶</a></h2>
|
||
<h3 id="pulling-updates">Pulling Updates<a class="headerlink" href="#pulling-updates" title="Permanent link">¶</a></h3>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-22-1"><a id="__codelineno-22-1" name="__codelineno-22-1" href="#__codelineno-22-1"></a><span class="c1"># Pull latest code</span>
|
||
</span><span id="__span-22-2"><a id="__codelineno-22-2" name="__codelineno-22-2" href="#__codelineno-22-2"></a>git<span class="w"> </span>pull<span class="w"> </span>origin<span class="w"> </span>main
|
||
</span><span id="__span-22-3"><a id="__codelineno-22-3" name="__codelineno-22-3" href="#__codelineno-22-3"></a>
|
||
</span><span id="__span-22-4"><a id="__codelineno-22-4" name="__codelineno-22-4" href="#__codelineno-22-4"></a><span class="c1"># Rebuild and restart containers</span>
|
||
</span><span id="__span-22-5"><a id="__codelineno-22-5" name="__codelineno-22-5" href="#__codelineno-22-5"></a>docker<span class="w"> </span>compose<span class="w"> </span>build<span class="w"> </span>api<span class="w"> </span>admin
|
||
</span><span id="__span-22-6"><a id="__codelineno-22-6" name="__codelineno-22-6" href="#__codelineno-22-6"></a>docker<span class="w"> </span>compose<span class="w"> </span>up<span class="w"> </span>-d<span class="w"> </span>api<span class="w"> </span>admin
|
||
</span><span id="__span-22-7"><a id="__codelineno-22-7" name="__codelineno-22-7" href="#__codelineno-22-7"></a>
|
||
</span><span id="__span-22-8"><a id="__codelineno-22-8" name="__codelineno-22-8" href="#__codelineno-22-8"></a><span class="c1"># Run any new migrations</span>
|
||
</span><span id="__span-22-9"><a id="__codelineno-22-9" name="__codelineno-22-9" href="#__codelineno-22-9"></a>docker<span class="w"> </span>compose<span class="w"> </span><span class="nb">exec</span><span class="w"> </span>api<span class="w"> </span>npx<span class="w"> </span>prisma<span class="w"> </span>migrate<span class="w"> </span>deploy
|
||
</span></code></pre></div>
|
||
<h3 id="database-migrations">Database Migrations<a class="headerlink" href="#database-migrations" title="Permanent link">¶</a></h3>
|
||
<p>Always run migrations after pulling updates:</p>
|
||
<div class="language-bash highlight"><pre><span></span><code><span id="__span-23-1"><a id="__codelineno-23-1" name="__codelineno-23-1" href="#__codelineno-23-1"></a>docker<span class="w"> </span>compose<span class="w"> </span><span class="nb">exec</span><span class="w"> </span>api<span class="w"> </span>npx<span class="w"> </span>prisma<span class="w"> </span>migrate<span class="w"> </span>deploy
|
||
</span></code></pre></div>
|
||
<div class="admonition warning">
|
||
<p class="admonition-title">Back up first</p>
|
||
<p>Always run <code>./scripts/backup.sh</code> before applying migrations in production. Migrations may alter table structures and are not easily reversible.</p>
|
||
</div>
|
||
<hr />
|
||
<h2 id="troubleshooting-production-issues">Troubleshooting Production Issues<a class="headerlink" href="#troubleshooting-production-issues" title="Permanent link">¶</a></h2>
|
||
<h3 id="pangolin-302-redirects-instead-of-content">Pangolin: 302 Redirects Instead of Content<a class="headerlink" href="#pangolin-302-redirects-instead-of-content" title="Permanent link">¶</a></h3>
|
||
<p><strong>Symptom:</strong> API returns 302 redirects to the Pangolin authentication page.</p>
|
||
<p><strong>Fix:</strong> In the Pangolin dashboard, edit each resource and set Authentication to <strong>Not Protected</strong>.</p>
|
||
<h3 id="cors-errors">CORS Errors<a class="headerlink" href="#cors-errors" title="Permanent link">¶</a></h3>
|
||
<p><strong>Symptom:</strong> Browser console shows CORS errors when accessing the production domain.</p>
|
||
<p><strong>Fix:</strong> Add your production <code>app.</code> subdomain to <code>CORS_ORIGINS</code> in <code>.env</code>, then <code>docker compose restart api</code>.</p>
|
||
<h3 id="newt-wont-connect">Newt Won't Connect<a class="headerlink" href="#newt-wont-connect" title="Permanent link">¶</a></h3>
|
||
<p>Check in order:</p>
|
||
<ol>
|
||
<li><strong>Credentials:</strong> Verify <code>PANGOLIN_NEWT_ID</code> and <code>PANGOLIN_NEWT_SECRET</code> in <code>.env</code></li>
|
||
<li><strong>Endpoint:</strong> Confirm <code>PANGOLIN_ENDPOINT</code> matches your Pangolin server URL</li>
|
||
<li><strong>Logs:</strong> <code>docker compose logs newt --tail 50</code></li>
|
||
<li><strong>Nginx running:</strong> Newt depends on nginx — <code>docker compose ps nginx</code></li>
|
||
<li><strong>Network:</strong> Ensure outbound HTTPS is not blocked by your firewall</li>
|
||
</ol>
|
||
<h3 id="services-unreachable-via-tunnel">Services Unreachable via Tunnel<a class="headerlink" href="#services-unreachable-via-tunnel" title="Permanent link">¶</a></h3>
|
||
<ol>
|
||
<li>Verify nginx is running: <code>docker compose ps nginx</code></li>
|
||
<li>Test locally first: <code>curl http://localhost:4000/api/health</code></li>
|
||
<li>Check nginx logs: <code>docker compose logs nginx --tail 50</code></li>
|
||
<li>Verify DNS: <code>dig app.yourdomain.org</code> should point to your Pangolin server</li>
|
||
</ol>
|
||
<p>See <a href="../troubleshooting/">Troubleshooting</a> for more common issues.</p>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
</article>
|
||
</div>
|
||
|
||
|
||
<script>var tabs=__md_get("__tabs");if(Array.isArray(tabs))e:for(var set of document.querySelectorAll(".tabbed-set")){var labels=set.querySelector(".tabbed-labels");for(var tab of tabs)for(var label of labels.getElementsByTagName("label"))if(label.innerText.trim()===tab){var input=document.getElementById(label.htmlFor);input.checked=!0;continue e}}</script>
|
||
|
||
<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script>
|
||
</div>
|
||
|
||
<button type="button" class="md-top md-icon" data-md-component="top" hidden>
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg>
|
||
Back to top
|
||
</button>
|
||
|
||
</main>
|
||
|
||
<footer class="md-footer">
|
||
|
||
|
||
|
||
<nav class="md-footer__inner md-grid" aria-label="Footer" >
|
||
|
||
|
||
<a href="../volunteer/achievements/" class="md-footer__link md-footer__link--prev" aria-label="Previous: Achievements">
|
||
<div class="md-footer__button md-icon">
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg>
|
||
</div>
|
||
<div class="md-footer__title">
|
||
<span class="md-footer__direction">
|
||
Previous
|
||
</span>
|
||
<div class="md-ellipsis">
|
||
Achievements
|
||
</div>
|
||
</div>
|
||
</a>
|
||
|
||
|
||
|
||
<a href="../architecture/" class="md-footer__link md-footer__link--next" aria-label="Next: Architecture">
|
||
<div class="md-footer__title">
|
||
<span class="md-footer__direction">
|
||
Next
|
||
</span>
|
||
<div class="md-ellipsis">
|
||
Architecture
|
||
</div>
|
||
</div>
|
||
<div class="md-footer__button md-icon">
|
||
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 11v2h12l-5.5 5.5 1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5 16 11z"/></svg>
|
||
</div>
|
||
</a>
|
||
|
||
</nav>
|
||
|
||
|
||
<div class="md-footer-meta md-typeset">
|
||
<div class="md-footer-meta__inner md-grid">
|
||
<div class="md-copyright">
|
||
|
||
<div class="md-copyright__highlight">
|
||
Copyright © 2024–2026 The Bunker Operations – <a href="#__consent">Change cookie settings</a>
|
||
|
||
</div>
|
||
|
||
|
||
Made with
|
||
<a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
|
||
Material for MkDocs
|
||
</a>
|
||
|
||
</div>
|
||
|
||
|
||
<div class="md-social">
|
||
|
||
|
||
|
||
|
||
|
||
<a href="https://gitea.bnkops.com/admin" target="_blank" rel="noopener" title="Gitea Repository" class="md-social__link">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M80 104a24 24 0 1 0 0-48 24 24 0 1 0 0 48m80-24c0 32.8-19.7 61-48 73.3V224h176c26.5 0 48-21.5 48-48v-22.7c-28.3-12.3-48-40.5-48-73.3 0-44.2 35.8-80 80-80s80 35.8 80 80c0 32.8-19.7 61-48 73.3V176c0 61.9-50.1 112-112 112H112v70.7c28.3 12.3 48 40.5 48 73.3 0 44.2-35.8 80-80 80S0 476.2 0 432c0-32.8 19.7-61 48-73.3V153.4C19.7 141 0 112.8 0 80 0 35.8 35.8 0 80 0s80 35.8 80 80m232 0a24 24 0 1 0-48 0 24 24 0 1 0 48 0M80 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48"/></svg>
|
||
</a>
|
||
|
||
|
||
|
||
|
||
|
||
<a href="https://listmonk.bnkops.com/subscription/form" target="_blank" rel="noopener" title="Newsletter" class="md-social__link">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M536.4-26.3c9.8-3.5 20.6-1 28 6.3s9.8 18.2 6.3 28l-178 496.9c-5 13.9-18.1 23.1-32.8 23.1-14.2 0-27-8.6-32.3-21.7l-64.2-158c-4.5-11-2.5-23.6 5.2-32.6l94.5-112.4c5.1-6.1 4.7-15-.9-20.6s-14.6-6-20.6-.9l-112.4 94.3c-9.1 7.6-21.6 9.6-32.6 5.2L38.1 216.8c-13.1-5.3-21.7-18.1-21.7-32.3 0-14.7 9.2-27.8 23.1-32.8z"/></svg>
|
||
</a>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
|
||
</div>
|
||
<div class="md-dialog" data-md-component="dialog">
|
||
<div class="md-dialog__inner md-typeset"></div>
|
||
</div>
|
||
|
||
<div class="md-progress" data-md-component="progress" role="progressbar"></div>
|
||
|
||
|
||
|
||
|
||
|
||
<script id="__config" type="application/json">{"annotate": null, "base": "../..", "features": ["announce.dismiss", "content.action.edit", "content.action.view", "content.code.annotate", "content.code.copy", "content.code.select", "content.tabs.link", "content.tooltips", "navigation.footer", "navigation.indexes", "navigation.instant", "navigation.instant.prefetch", "navigation.instant.progress", "navigation.path", "navigation.prune", "navigation.tabs", "navigation.tabs.sticky", "navigation.top", "navigation.tracking", "search.highlight", "search.share", "search.suggest", "toc.follow"], "search": "../../assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>
|
||
|
||
|
||
<script src="../../assets/javascripts/bundle.79ae519e.min.js"></script>
|
||
|
||
<script src="../../javascripts/home.js"></script>
|
||
|
||
<script src="../../javascripts/github-widget.js"></script>
|
||
|
||
<script src="../../javascripts/gitea-widget.js"></script>
|
||
|
||
<script src="../../assets/js/env-config.js"></script>
|
||
|
||
<script src="../../assets/js/video-player.js"></script>
|
||
|
||
<script src="../../assets/js/image-gallery.js"></script>
|
||
|
||
<script src="../../assets/js/gancio-events.js"></script>
|
||
|
||
<script src="../../assets/js/payment-widgets.js"></script>
|
||
|
||
<script src="../../assets/js/scheduling-poll.js"></script>
|
||
|
||
<script src="../../assets/js/straw-poll-widget.js"></script>
|
||
|
||
<script src="../../javascripts/ad-widgets.js"></script>
|
||
|
||
<script src="../../javascripts/docs-comments.js"></script>
|
||
|
||
|
||
</body>
|
||
</html> |