- docs/hosting.html: step-by-step setup of app.quorlasocial.com with Docker - env.js: trim spaces/tabs Docker keeps after "=" (broke the Meta token) - Dockerfile: chown only writable folders (build step 55s -> 2s) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
412 lines
32 KiB
HTML
412 lines
32 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>Quorla Server Setup</title>
|
||
<meta name="claude:viewport-fit-sides" content="cover">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
|
||
<style>
|
||
/* Layout: runbook — sticky contents rail on wide screens, one reading column of numbered steps. */
|
||
:root {
|
||
--bg: #f6f7f8;
|
||
--surface: #ffffff;
|
||
--ink: #17202a;
|
||
--ink-2: #44505c;
|
||
--muted: #6a7581;
|
||
--line: #dde2e7;
|
||
--accent: #0f6e78;
|
||
--accent-soft: #e3f1f2;
|
||
--code-bg: #10181f;
|
||
--code-ink: #dbe6ee;
|
||
--code-dim: #7f93a3;
|
||
--warn: #8a5300;
|
||
--warn-soft: #fbf0dc;
|
||
--bad: #a8321f;
|
||
--bad-soft: #fbe9e5;
|
||
--ok: #2a6b45;
|
||
--font-body: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||
--font-mono: "IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) {
|
||
--bg: #0f1418; --surface: #161d23; --ink: #e6ecf0; --ink-2: #b9c4cc; --muted: #8a97a2; --line: #2a343c;
|
||
--accent: #5cc3cc; --accent-soft: #15333a; --code-bg: #0a0f13; --code-ink: #dbe6ee; --code-dim: #7f93a3;
|
||
--warn: #f0b35a; --warn-soft: #33270f; --bad: #f08a76; --bad-soft: #3a1c16; --ok: #7bcf9c; color-scheme: dark;
|
||
}
|
||
}
|
||
:root[data-theme="dark"] {
|
||
--bg: #0f1418; --surface: #161d23; --ink: #e6ecf0; --ink-2: #b9c4cc; --muted: #8a97a2; --line: #2a343c;
|
||
--accent: #5cc3cc; --accent-soft: #15333a; --code-bg: #0a0f13; --code-ink: #dbe6ee; --code-dim: #7f93a3;
|
||
--warn: #f0b35a; --warn-soft: #33270f; --bad: #f08a76; --bad-soft: #3a1c16; --ok: #7bcf9c; color-scheme: dark;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body { background: var(--bg); color: var(--ink); font-family: var(--font-body); font-size: 15.5px; line-height: 1.6; }
|
||
.wrap { max-width: 1120px; margin: 0 auto; padding-block: 40px 80px; padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: max(16px, env(safe-area-inset-right, 0px)); display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 48px; }
|
||
@media (max-width: 860px) { .wrap { grid-template-columns: minmax(0, 1fr); gap: 24px; padding-block: 24px 56px; } nav.toc { position: static !important; } }
|
||
nav.toc { position: sticky; top: calc(env(safe-area-inset-top, 0px) + 24px); align-self: start; font-size: 13.5px; }
|
||
nav.toc .label { font-size: 11px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); font-weight: 600; margin-bottom: 8px; }
|
||
nav.toc ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; counter-reset: t; }
|
||
nav.toc li { counter-increment: t; }
|
||
nav.toc a { display: flex; gap: 8px; color: var(--ink-2); text-decoration: none; padding: 4px 8px; border-radius: 6px; }
|
||
nav.toc a::before { content: counter(t); font-family: var(--font-mono); font-size: 12px; color: var(--muted); min-width: 1.4em; padding-top: 1px; }
|
||
nav.toc li.extra a::before { content: "·"; }
|
||
nav.toc a:hover { background: var(--accent-soft); color: var(--ink); }
|
||
main { min-width: 0; }
|
||
header.doc { border-bottom: 1px solid var(--line); padding-bottom: 24px; margin-bottom: 8px; }
|
||
.eyebrow { font-size: 12px; letter-spacing: .1em; text-transform: uppercase; color: var(--accent); font-weight: 600; }
|
||
h1 { font-size: clamp(28px, 4vw, 38px); line-height: 1.15; margin: 8px 0 12px; letter-spacing: -.01em; text-wrap: balance; }
|
||
.lede { font-size: 17px; color: var(--ink-2); max-width: 66ch; margin: 0; }
|
||
.meta { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 16px; font-size: 13.5px; color: var(--muted); }
|
||
.meta b { color: var(--ink-2); font-weight: 600; }
|
||
section { padding-top: 36px; scroll-margin-top: 16px; }
|
||
section > h2 { display: flex; align-items: baseline; gap: 12px; font-size: 22px; line-height: 1.3; margin: 0 0 6px; text-wrap: balance; }
|
||
section > h2 .n { font-family: var(--font-mono); font-size: 14px; font-weight: 500; color: var(--accent); border: 1px solid var(--accent); border-radius: 999px; min-width: 30px; height: 26px; display: inline-grid; place-items: center; padding: 0 8px; flex-shrink: 0; transform: translateY(-2px); }
|
||
.who { font-size: 13px; color: var(--muted); margin: 0 0 14px; }
|
||
.who b { color: var(--ink-2); }
|
||
h3 { font-size: 16px; margin: 22px 0 6px; }
|
||
p, li { max-width: 70ch; }
|
||
p { margin: 8px 0; }
|
||
ul, ol.steps { padding-left: 1.3em; margin: 8px 0; }
|
||
li { margin: 4px 0; }
|
||
a { color: var(--accent); }
|
||
code { font-family: var(--font-mono); font-size: .88em; background: var(--accent-soft); color: var(--ink); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
|
||
.cmd { position: relative; margin: 10px 0 14px; border-radius: 8px; background: var(--code-bg); }
|
||
.cmd pre { margin: 0; padding: 14px 16px; padding-right: 72px; overflow-x: auto; font-family: var(--font-mono); font-size: 13.5px; line-height: 1.6; color: var(--code-ink); }
|
||
.cmd pre .c { color: var(--code-dim); }
|
||
.cmd .where { position: absolute; top: -9px; left: 12px; font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; background: var(--accent); color: var(--surface); padding: 1px 8px; border-radius: 4px; font-weight: 600; }
|
||
.cmd button { position: absolute; top: 8px; right: 8px; font: 500 12px var(--font-body); color: var(--code-ink); background: transparent; border: 1px solid var(--code-dim); border-radius: 5px; padding: 3px 9px; cursor: pointer; }
|
||
.cmd button:hover { border-color: var(--code-ink); }
|
||
.cmd button:focus-visible, nav.toc a:focus-visible, .check input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
.note { border-radius: 8px; padding: 12px 14px; margin: 14px 0; font-size: 14.5px; border: 1px solid; }
|
||
.note p { margin: 4px 0; }
|
||
.note strong:first-child { display: block; margin-bottom: 2px; }
|
||
.note.warn { background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 35%, transparent); color: var(--ink); }
|
||
.note.warn strong:first-child { color: var(--warn); }
|
||
.note.stop { background: var(--bad-soft); border-color: color-mix(in srgb, var(--bad) 35%, transparent); }
|
||
.note.stop strong:first-child { color: var(--bad); }
|
||
.note.info { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
|
||
.note.info strong:first-child { color: var(--accent); }
|
||
.check { list-style: none; padding: 0; margin: 10px 0; display: grid; gap: 6px; }
|
||
.check li { display: flex; gap: 10px; align-items: flex-start; max-width: none; }
|
||
.check input { margin-top: 5px; width: 16px; height: 16px; accent-color: var(--accent); flex-shrink: 0; }
|
||
.check label { cursor: pointer; }
|
||
.check input:checked + label { color: var(--muted); text-decoration: line-through; text-decoration-color: var(--line); }
|
||
.tbl-wrap { overflow-x: auto; margin: 12px 0; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
|
||
table { border-collapse: collapse; width: 100%; font-size: 14px; }
|
||
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||
th { font-size: 11.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); font-weight: 600; background: var(--bg); }
|
||
tr:last-child td { border-bottom: 0; }
|
||
td code { white-space: nowrap; }
|
||
.diagram { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; margin: 18px 0 8px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); overflow: hidden; }
|
||
.diagram > div { padding: 14px; border-right: 1px solid var(--line); min-width: 0; }
|
||
.diagram > div:last-child { border-right: 0; }
|
||
.diagram .k { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); font-weight: 600; }
|
||
.diagram .v { font-weight: 600; margin: 2px 0; overflow-wrap: anywhere; }
|
||
.diagram .d { font-size: 13px; color: var(--ink-2); }
|
||
@media (max-width: 640px) { .diagram { grid-template-columns: minmax(0, 1fr); } .diagram > div { border-right: 0; border-bottom: 1px solid var(--line); } .diagram > div:last-child { border-bottom: 0; } }
|
||
footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--line); font-size: 13px; color: var(--muted); }
|
||
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
|
||
html { scroll-behavior: smooth; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="wrap">
|
||
<nav class="toc" aria-label="Contents">
|
||
<div class="label">Steps</div>
|
||
<ol>
|
||
<li><a href="#before">Before you start</a></li>
|
||
<li><a href="#dns">Point the domain</a></li>
|
||
<li><a href="#server">Prepare the server</a></li>
|
||
<li><a href="#code">Get the code</a></li>
|
||
<li><a href="#env">Add the settings file</a></li>
|
||
<li><a href="#data">Move the data</a></li>
|
||
<li><a href="#start">Start Quorla</a></li>
|
||
<li><a href="#verify">Check everything works</a></li>
|
||
<li><a href="#switch">Switch over</a></li>
|
||
<li><a href="#backups">Backups</a></li>
|
||
<li class="extra"><a href="#updates">Updating</a></li>
|
||
<li class="extra"><a href="#commands">Everyday commands</a></li>
|
||
<li class="extra"><a href="#trouble">Troubleshooting</a></li>
|
||
</ol>
|
||
</nav>
|
||
|
||
<main>
|
||
<header class="doc">
|
||
<div class="eyebrow">Hosting runbook</div>
|
||
<h1>Set up Quorla on app.quorlasocial.com</h1>
|
||
<p class="lede">This moves the social media dashboard from Manesh's Windows PC to a Linux server, so the whole team can use it from anywhere over HTTPS. Follow the steps in order. Commands are copy-and-paste; anything in <code>CAPITALS</code> is something you replace.</p>
|
||
<div class="meta">
|
||
<span><b>Time:</b> about 1½–2 hours</span>
|
||
<span><b>Downtime:</b> about 15 minutes (step 6 to step 9)</span>
|
||
<span><b>Skills:</b> SSH and basic Linux commands</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="diagram" role="img" aria-label="Team browser connects over HTTPS to the server, where Caddy forwards to the Quorla app container, which stores data in folders on the server">
|
||
<div><div class="k">Who</div><div class="v">Team's browsers</div><div class="d">Canada and India</div></div>
|
||
<div><div class="k">Address</div><div class="v">app.quorlasocial.com</div><div class="d">HTTPS, port 443</div></div>
|
||
<div><div class="k">On the server</div><div class="v">Caddy → Quorla app</div><div class="d">Two Docker containers</div></div>
|
||
<div><div class="k">Stored in</div><div class="v">/opt/quorla/</div><div class="d">data, clients, team, reports, branding</div></div>
|
||
</div>
|
||
<p style="font-size:13.5px;color:var(--muted);margin-top:6px">Caddy gets and renews the HTTPS certificate automatically. The app container also runs the publishing queue every 2 minutes, so nothing else needs scheduling.</p>
|
||
|
||
<section id="before">
|
||
<h2><span class="n">1</span>Before you start</h2>
|
||
<p class="who"><b>Who:</b> you, with Manesh for the items marked ★</p>
|
||
<ul class="check">
|
||
<li><input type="checkbox" id="c-server"><label for="c-server"><b>A server:</b> Ubuntu 24.04 LTS, at least 2 vCPU, 4 GB RAM, 40 GB disk (Hetzner, DigitalOcean, Hostinger VPS or similar), with its public IP address and root or sudo SSH access.</label></li>
|
||
<li><input type="checkbox" id="c-dns"><label for="c-dns"><b>DNS access</b> for <code>quorlasocial.com</code> (wherever the domain is managed).</label></li>
|
||
<li><input type="checkbox" id="c-git"><label for="c-git"><b>Read access</b> to <code>git.metatroncube.in/MetatroncubeSoftwareSolutions/quorlasocial</code>.</label></li>
|
||
<li><input type="checkbox" id="c-env"><label for="c-env">★ <b>The <code>.env</code> settings file</b> from Manesh's PC. It holds the Meta token and Odoo key: get it through a password manager or an encrypted transfer, never in chat or email.</label></li>
|
||
<li><input type="checkbox" id="c-api"><label for="c-api">★ <b>An Anthropic API key</b> (console.anthropic.com → API keys) with credit on it. The AI runs (audits, plans, reports) need it on a server.</label></li>
|
||
<li><input type="checkbox" id="c-time"><label for="c-time">★ <b>A time slot</b> when Manesh can stop the dashboard on his PC (step 6) and no posts are due in the next 30 minutes.</label></li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section id="dns">
|
||
<h2><span class="n">2</span>Point the domain at the server</h2>
|
||
<p class="who"><b>Where:</b> the DNS settings for quorlasocial.com</p>
|
||
<p>Add one record. Do this first, because it can take a few minutes to an hour to spread.</p>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr><th>Type</th><th>Name / Host</th><th>Value</th><th>TTL</th></tr></thead>
|
||
<tbody><tr><td>A</td><td><code>app</code></td><td><code>SERVER_IP</code></td><td>300 (or Auto)</td></tr></tbody>
|
||
</table></div>
|
||
<div class="note warn"><strong>If the DNS is on Cloudflare</strong><p>Set the record to <b>DNS only</b> (grey cloud) for now. Caddy needs to reach the server directly to issue the certificate.</p></div>
|
||
<p>Check it from your own computer. It should print the server's IP:</p>
|
||
<div class="cmd"><span class="where">Your computer</span><pre>nslookup app.quorlasocial.com</pre><button type="button">Copy</button></div>
|
||
</section>
|
||
|
||
<section id="server">
|
||
<h2><span class="n">3</span>Prepare the server</h2>
|
||
<p class="who"><b>Where:</b> SSH into the server as root</p>
|
||
<h3>3a. Updates, a working user, and the firewall</h3>
|
||
<div class="cmd"><span class="where">Server · root</span><pre>ssh root@SERVER_IP
|
||
|
||
apt update && apt -y upgrade
|
||
apt -y install git ufw unattended-upgrades
|
||
timedatectl set-timezone UTC
|
||
|
||
<span class="c"># A normal user to run Quorla (replace YOURNAME)</span>
|
||
adduser YOURNAME
|
||
usermod -aG sudo YOURNAME
|
||
|
||
<span class="c"># Firewall: only SSH and web traffic</span>
|
||
ufw allow OpenSSH
|
||
ufw allow 80/tcp
|
||
ufw allow 443/tcp
|
||
ufw allow 443/udp
|
||
ufw --force enable</pre><button type="button">Copy</button></div>
|
||
<div class="note info"><strong>SSH keys</strong><p>If you log in with a password, add your SSH key now (<code>ssh-copy-id YOURNAME@SERVER_IP</code> from your computer) and then turn off password logins in <code>/etc/ssh/sshd_config</code> (<code>PasswordAuthentication no</code>, then <code>systemctl restart ssh</code>).</p></div>
|
||
|
||
<h3>3b. Install Docker</h3>
|
||
<div class="cmd"><span class="where">Server · root</span><pre>curl -fsSL https://get.docker.com | sh
|
||
usermod -aG docker YOURNAME
|
||
docker --version
|
||
docker compose version</pre><button type="button">Copy</button></div>
|
||
<p>Both version commands should print a version number. Log out, then log back in as your user for the rest of the guide:</p>
|
||
<div class="cmd"><span class="where">Your computer</span><pre>ssh YOURNAME@SERVER_IP</pre><button type="button">Copy</button></div>
|
||
</section>
|
||
|
||
<section id="code">
|
||
<h2><span class="n">4</span>Get the code</h2>
|
||
<p class="who"><b>Where:</b> server, as your user</p>
|
||
<div class="cmd"><span class="where">Server · your user</span><pre>sudo mkdir -p /opt/quorla
|
||
sudo chown $USER:$USER /opt/quorla
|
||
git clone https://git.metatroncube.in/MetatroncubeSoftwareSolutions/quorlasocial.git /opt/quorla
|
||
cd /opt/quorla
|
||
ls</pre><button type="button">Copy</button></div>
|
||
<p>You should see <code>Dockerfile</code>, <code>docker-compose.yml</code>, <code>deploy/</code>, <code>dashboard/</code> and <code>connector/</code>. Git asks for your git.metatroncube.in username and password (or an access token) when cloning.</p>
|
||
<p>The repository contains only the program. Client files, logins, uploads and secrets are deliberately left out of Git; they come across in steps 5 and 6.</p>
|
||
</section>
|
||
|
||
<section id="env">
|
||
<h2><span class="n">5</span>Add the settings file</h2>
|
||
<p class="who"><b>Where:</b> server, in <code>/opt/quorla</code></p>
|
||
<p>Put Manesh's <code>.env</code> file in <code>/opt/quorla/.env</code> (for example with <code>scp .env YOURNAME@SERVER_IP:/opt/quorla/</code> from the computer where you received it). Then lock it down and open it:</p>
|
||
<div class="cmd"><span class="where">Server · your user</span><pre>cd /opt/quorla
|
||
chmod 600 .env
|
||
sed -i 's/\r$//' .env <span class="c"># removes Windows line endings, if any</span>
|
||
nano .env</pre><button type="button">Copy</button></div>
|
||
<p>Check or add these lines. Leave everything else as it is.</p>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr><th>Setting</th><th>Value</th><th>Why</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><code>APP_DOMAIN</code></td><td><code>app.quorlasocial.com</code></td><td>The address Caddy gets a certificate for.</td></tr>
|
||
<tr><td><code>ACME_EMAIL</code></td><td><code>support@quorlasocial.com</code></td><td>Certificate expiry notices.</td></tr>
|
||
<tr><td><code>DASHBOARD_SECRET</code></td><td>keep the existing value; if empty, run <code>openssl rand -hex 32</code> and paste the result</td><td>Keeps people signed in across restarts. Changing it signs everyone out.</td></tr>
|
||
<tr><td><code>ANTHROPIC_API_KEY</code></td><td>the key from step 1</td><td>AI runs. Without it, audits and plans fail on the server.</td></tr>
|
||
<tr><td><code>ALLOW_PUBLISH</code></td><td><code>false</code> <b>for now</b></td><td>You turn it on in step 9, after the PC is switched off.</td></tr>
|
||
</tbody>
|
||
</table></div>
|
||
<div class="note warn"><strong>No spaces after the = sign</strong><p>Write <code>KEY=value</code>, never <code>KEY= value</code>. A stray space or tab becomes part of the value and breaks tokens. Save in nano with <b>Ctrl+O</b>, <b>Enter</b>, then <b>Ctrl+X</b>.</p></div>
|
||
<p>You don't need to change <code>SCHEDULER_ENABLED</code>, <code>DASHBOARD_HOST</code> or <code>DASHBOARD_COOKIE_SECURE</code>; Docker sets those for the server.</p>
|
||
</section>
|
||
|
||
<section id="data">
|
||
<h2><span class="n">6</span>Move the data from the PC</h2>
|
||
<p class="who"><b>Who:</b> Manesh on the PC, then you on the server. <b>Downtime starts here.</b></p>
|
||
<h3>6a. On Manesh's PC: stop the dashboard and pack the data</h3>
|
||
<p>Stop the dashboard (close its terminal window, or press <b>Ctrl+C</b> in it), then in PowerShell:</p>
|
||
<div class="cmd"><span class="where">Windows PC · PowerShell</span><pre>cd D:\MCS_Repos\2026\claude_Metatron_Social_Media_Manager
|
||
tar -czf quorla-data.tar.gz data clients team reports branding
|
||
scp quorla-data.tar.gz YOURNAME@SERVER_IP:/opt/quorla/</pre><button type="button">Copy</button></div>
|
||
<p>This archive holds client files and everyone's (hashed) logins. Delete it from the PC once step 8 has passed.</p>
|
||
<h3>6b. On the server: unpack it</h3>
|
||
<div class="cmd"><span class="where">Server · your user</span><pre>cd /opt/quorla
|
||
tar -xzf quorla-data.tar.gz
|
||
sudo chown -R 1000:1000 data clients team reports branding
|
||
rm quorla-data.tar.gz
|
||
ls data <span class="c"># should list: dashboard library schedule publish-log.jsonl ...</span></pre><button type="button">Copy</button></div>
|
||
<p>The <code>chown</code> matters: the app inside Docker runs as user 1000 and must be able to write to these folders.</p>
|
||
</section>
|
||
|
||
<section id="start">
|
||
<h2><span class="n">7</span>Start Quorla</h2>
|
||
<p class="who"><b>Where:</b> server, in <code>/opt/quorla</code></p>
|
||
<div class="cmd"><span class="where">Server · your user</span><pre>cd /opt/quorla
|
||
docker compose up -d --build</pre><button type="button">Copy</button></div>
|
||
<p>The first build takes 3–6 minutes. Then fix the stored file locations (the PC used Windows paths like <code>D:\…</code>). Run it once to preview, then again with <code>--write</code>:</p>
|
||
<div class="cmd"><span class="where">Server · your user</span><pre>docker compose exec app node connector/scripts/relocate-paths.js
|
||
docker compose exec app node connector/scripts/relocate-paths.js --write</pre><button type="button">Copy</button></div>
|
||
<p>The last line should say <code>0 file(s) not found</code>. Then check both containers are running and watch the log for a moment:</p>
|
||
<div class="cmd"><span class="where">Server · your user</span><pre>docker compose ps
|
||
docker compose logs -f --tail=50</pre><button type="button">Copy</button></div>
|
||
<p><code>app</code> should show <b>healthy</b> after about 30 seconds, and the Caddy log should mention <code>certificate obtained successfully</code>. Press <b>Ctrl+C</b> to stop watching the log (Quorla keeps running).</p>
|
||
</section>
|
||
|
||
<section id="verify">
|
||
<h2><span class="n">8</span>Check everything works</h2>
|
||
<p class="who"><b>Where:</b> your browser, signed in as an admin</p>
|
||
<p>Open <b>https://app.quorlasocial.com</b>. Everyone's existing logins came across with the data, so Manesh signs in with his usual email and password.</p>
|
||
<ul class="check">
|
||
<li><input type="checkbox" id="v-https"><label for="v-https">The browser shows a padlock (valid HTTPS) and the sign-in page loads.</label></li>
|
||
<li><input type="checkbox" id="v-login"><label for="v-login">Signing in works, and you stay signed in after refreshing the page.</label></li>
|
||
<li><input type="checkbox" id="v-meta"><label for="v-meta"><b>Settings → Connections:</b> Meta shows the connected Pages (9 at the time of writing), Odoo shows connected, Claude says it is using the API key, Publishing says Off.</label></li>
|
||
<li><input type="checkbox" id="v-team"><label for="v-team"><b>Settings → Team members</b> lists the same people as before.</label></li>
|
||
<li><input type="checkbox" id="v-clients"><label for="v-clients"><b>Clients</b> shows every client with numbers; open one and its audit opens.</label></li>
|
||
<li><input type="checkbox" id="v-lib"><label for="v-lib"><b>Content library</b> thumbnails load, and uploading a small test image works (archive it afterwards).</label></li>
|
||
<li><input type="checkbox" id="v-pdf"><label for="v-pdf">Exporting a client's audit to PDF works.</label></li>
|
||
<li><input type="checkbox" id="v-inbox"><label for="v-inbox"><b>Inbox</b> loads comments for a client.</label></li>
|
||
</ul>
|
||
<p>You can also check the Meta connection from the server:</p>
|
||
<div class="cmd"><span class="where">Server · your user</span><pre>docker compose exec app node connector/scripts/test-meta.js</pre><button type="button">Copy</button></div>
|
||
</section>
|
||
|
||
<section id="switch">
|
||
<h2><span class="n">9</span>Switch over</h2>
|
||
<p class="who"><b>Who:</b> Manesh on the PC, then you on the server</p>
|
||
<div class="note stop"><strong>Only one place may publish</strong><p>If the PC and the server both run the queue, approved posts can go out twice. Finish 9a before 9b.</p></div>
|
||
<h3>9a. On the PC: make sure nothing there can post</h3>
|
||
<ul>
|
||
<li>In the PC's <code>.env</code>, set <code>ALLOW_PUBLISH=false</code>.</li>
|
||
<li>Don't start the dashboard on the PC again (no <code>npm start</code>).</li>
|
||
<li>Check that no Windows scheduled task exists for the queue; this should say the task cannot be found:</li>
|
||
</ul>
|
||
<div class="cmd"><span class="where">Windows PC · PowerShell</span><pre>schtasks /query /tn "SocialManagerScheduler"
|
||
<span class="c"># Only if it exists:</span>
|
||
schtasks /delete /tn "SocialManagerScheduler" /f</pre><button type="button">Copy</button></div>
|
||
<h3>9b. On the server: turn publishing on</h3>
|
||
<div class="cmd"><span class="where">Server · your user</span><pre>cd /opt/quorla
|
||
sed -i 's/^ALLOW_PUBLISH=.*/ALLOW_PUBLISH=true/' .env
|
||
docker compose up -d</pre><button type="button">Copy</button></div>
|
||
<p>In <b>Settings → Connections</b>, Publishing now says <b>On</b>. Every post still needs an admin's approval before it goes out. <b>Downtime ends here.</b></p>
|
||
<h3>9c. Tell the team</h3>
|
||
<p>Send everyone the new address, <b>https://app.quorlasocial.com</b>. Their email and password are unchanged. The India team needs nothing extra: no VPN, just the browser.</p>
|
||
</section>
|
||
|
||
<section id="backups">
|
||
<h2><span class="n">10</span>Set up daily backups</h2>
|
||
<p class="who"><b>Where:</b> server, as your user</p>
|
||
<p><code>deploy/backup.sh</code> packs the settings, logins, uploads, queue, client files and reports into <code>/opt/quorla/backups/</code> and keeps the newest 14. Run it once by hand, then schedule it for 02:30 UTC every night:</p>
|
||
<div class="cmd"><span class="where">Server · your user</span><pre>cd /opt/quorla
|
||
sudo sh deploy/backup.sh
|
||
( crontab -l 2>/dev/null; echo "30 2 * * * cd /opt/quorla && sh deploy/backup.sh >> backups/backup.log 2>&1" ) | sudo crontab -</pre><button type="button">Copy</button></div>
|
||
<div class="note warn"><strong>Copy backups off the server</strong><p>A backup on the same server doesn't survive the server being lost. Copy <code>backups/</code> somewhere else weekly (another server, or encrypted cloud storage). The backups contain the <code>.env</code> with the Meta token and API keys, so keep them private and encrypted.</p></div>
|
||
<h3>Restoring a backup</h3>
|
||
<div class="cmd"><span class="where">Server · your user</span><pre>cd /opt/quorla
|
||
docker compose down
|
||
sudo tar -xzf backups/quorla-YYYY-MM-DD-HHMM.tar.gz
|
||
sudo chown -R 1000:1000 data clients team reports branding
|
||
docker compose up -d</pre><button type="button">Copy</button></div>
|
||
</section>
|
||
|
||
<section id="updates">
|
||
<h2>Updating Quorla</h2>
|
||
<p>When a new version is pushed to Git:</p>
|
||
<div class="cmd"><span class="where">Server · your user</span><pre>cd /opt/quorla
|
||
sudo sh deploy/backup.sh
|
||
git pull
|
||
docker compose up -d --build
|
||
docker compose ps <span class="c"># app should be "healthy" again within a minute</span></pre><button type="button">Copy</button></div>
|
||
<p>To roll back, run <code>git log --oneline</code>, pick the previous version, then <code>git checkout THAT_ID</code> and <code>docker compose up -d --build</code>. Your data isn't touched by updates or rollbacks.</p>
|
||
<p>Avoid updating while a post is due in the next few minutes; the queue pauses for about a minute during the restart, and anything due in that time goes out right after.</p>
|
||
</section>
|
||
|
||
<section id="commands">
|
||
<h2>Everyday commands</h2>
|
||
<p>Run these from <code>/opt/quorla</code>.</p>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr><th>To…</th><th>Run</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>See if it's running</td><td><code>docker compose ps</code></td></tr>
|
||
<tr><td>Read recent app logs</td><td><code>docker compose logs --tail=100 app</code></td></tr>
|
||
<tr><td>Restart after changing <code>.env</code></td><td><code>docker compose up -d</code></td></tr>
|
||
<tr><td>Restart without changes</td><td><code>docker compose restart app</code></td></tr>
|
||
<tr><td>Stop everything</td><td><code>docker compose down</code></td></tr>
|
||
<tr><td>Check the Meta connection</td><td><code>docker compose exec app node connector/scripts/test-meta.js</code></td></tr>
|
||
<tr><td>Recover admin access if nobody can sign in</td><td><code>docker compose exec app node dashboard/server/scripts/add-user.js EMAIL "Name" admin</code> (prints a new password once)</td></tr>
|
||
<tr><td>See disk space</td><td><code>df -h /</code> and <code>du -sh data clients backups</code></td></tr>
|
||
</tbody>
|
||
</table></div>
|
||
<p>Adding teammates, resetting passwords and changing roles are done in the dashboard under <b>Settings → Team members</b>, not on the server.</p>
|
||
</section>
|
||
|
||
<section id="trouble">
|
||
<h2>Troubleshooting</h2>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr><th>What you see</th><th>Likely cause</th><th>Fix</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>Browser warns about the certificate, or the site doesn't load</td><td>DNS not pointing at the server yet, or ports 80/443 blocked</td><td>Check step 2 with <code>nslookup</code>; check <code>sudo ufw status</code> and the hosting provider's own firewall panel; then <code>docker compose restart caddy</code> and read <code>docker compose logs caddy</code>.</td></tr>
|
||
<tr><td>"502 Bad Gateway"</td><td>The app container isn't up</td><td><code>docker compose logs --tail=100 app</code>; usually a missing or mistyped line in <code>.env</code>.</td></tr>
|
||
<tr><td><code>docker compose</code> says <i>Set APP_DOMAIN</i> or <i>Set ACME_EMAIL</i></td><td>Those lines are missing from <code>.env</code></td><td>Add them (step 5).</td></tr>
|
||
<tr><td>Settings shows Meta "Invalid OAuth access token"</td><td>A space or tab after <code>=</code>, or the token was revoked</td><td>Fix the <code>META_SYSTEM_USER_TOKEN</code> line, then <code>docker compose up -d</code>. If it still fails, Manesh generates a new token in Meta Business Settings.</td></tr>
|
||
<tr><td>Signing in works but you're sent back to the sign-in page</td><td>Not on HTTPS, or <code>DASHBOARD_SECRET</code> empty or changed</td><td>Always use <code>https://</code>; set <code>DASHBOARD_SECRET</code> once and don't change it.</td></tr>
|
||
<tr><td>Uploads or saving fail with "permission denied" in the log</td><td>Data folders not owned by user 1000</td><td><code>sudo chown -R 1000:1000 data clients team reports branding</code></td></tr>
|
||
<tr><td>AI runs fail straight away</td><td>No API key, no credit, or a model name the key can't use</td><td>Check <code>ANTHROPIC_API_KEY</code> and the balance at console.anthropic.com; try <code>DASHBOARD_AI_MODEL=claude-sonnet-5-5</code> in <code>.env</code>, then <code>docker compose up -d</code>.</td></tr>
|
||
<tr><td>Library thumbnails or scheduled posts say "file not found"</td><td>Paths not relocated after the move</td><td>Run the two <code>relocate-paths.js</code> commands from step 7.</td></tr>
|
||
<tr><td>The disk is filling up</td><td>Video uploads and backups</td><td><code>du -sh data/library backups</code>; archive old library months elsewhere, or move to a bigger disk.</td></tr>
|
||
</tbody>
|
||
</table></div>
|
||
<div class="note info"><strong>Keep port 4310 private</strong><p>The app listens only inside Docker; the team always comes in through Caddy on HTTPS. Don't publish port 4310 in <code>docker-compose.yml</code> or open it in the firewall.</p></div>
|
||
</section>
|
||
|
||
<footer>Quorla · hosting runbook for app.quorlasocial.com · matches the files in the quorlasocial repository (Dockerfile, docker-compose.yml, deploy/). If a command here doesn't match what you see, ask Manesh before improvising.</footer>
|
||
</main>
|
||
</div>
|
||
|
||
<script>
|
||
document.querySelectorAll('.cmd').forEach(function (box) {
|
||
var btn = box.querySelector('button');
|
||
var pre = box.querySelector('pre');
|
||
btn.addEventListener('click', function () {
|
||
var text = pre.innerText.split('\n').map(function (l) { return l.replace(/\s+#\s.*$/, ''); }).join('\n').replace(/^\s*#.*\n?/gm, '').trim();
|
||
var done = function () { btn.textContent = 'Copied'; setTimeout(function () { btn.textContent = 'Copy'; }, 1400); };
|
||
var fallback = function () { var r = document.createRange(); r.selectNodeContents(pre); var s = getSelection(); s.removeAllRanges(); s.addRange(r); btn.textContent = 'Selected'; setTimeout(function () { btn.textContent = 'Copy'; }, 1400); };
|
||
try { navigator.clipboard.writeText(text).then(done, fallback); } catch (e) { fallback(); }
|
||
});
|
||
});
|
||
var KEY = 'quorla-hosting-checks';
|
||
var saved = {};
|
||
try { saved = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch (e) {}
|
||
document.querySelectorAll('.check input').forEach(function (cb) {
|
||
if (saved[cb.id]) cb.checked = true;
|
||
cb.addEventListener('change', function () { saved[cb.id] = cb.checked; try { localStorage.setItem(KEY, JSON.stringify(saved)); } catch (e) {} });
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|