quorlasocial/docs/hosting.html
metatroncubeswdev e107c046a2 Add server hosting runbook; trim stray whitespace in env values
- 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>
2026-10-19 13:05:41 -04:00

412 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 &amp;&amp; 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&gt;/dev/null; echo "30 2 * * * cd /opt/quorla &amp;&amp; sh deploy/backup.sh &gt;&gt; backups/backup.log 2&gt;&amp;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>