# Run your newsroom tools locally: PostgreSQL + PM2

A simple, free setup for running internal apps (CMS, archives, bots) on **one always-on computer in the newsroom** — no cloud bill, no Docker complexity. Staff laptops and phones on the **same private Wi‑Fi** open those apps in a browser. PostgreSQL never leaves the host.

This works for a **small newsroom** (several desks, several phones) and for a **home office**. **macOS and Windows** (install steps differ; the architecture is the same).

**Who this is for**

- A handful of journalists, editors, and producers sharing one office (or home) network.
- One dedicated **host** that stays plugged in (not a reporter’s travel laptop).
- Staff devices are **clients**: they use the browser; they do not run Postgres.
- Visitors get a **separate guest Wi‑Fi** that cannot reach the host.

This is **not** a public website. Unpublished copy, sources, and internal tools stay on your LAN (or Tailscale). Put a login on every app; being on the Wi‑Fi is not authentication.

---

## The idea in one picture

```
Newsroom / office Wi‑Fi  (staff SSID, WPA2/WPA3 — guests stay off this network)
│
├── Always-on host (desk machine that stays in the room)
│     ├── PostgreSQL 16  ──►  one server, one database per app
│     │                       (cms, archive, bot, ...) — localhost only
│     ├── PM2  ──────────►  keeps every app running in the background
│     │                       restarts on crash, survives closing the terminal
│     └── Caddy ──────────►  the only LAN door: cms.newsroom.lan, …
│
├── Editor laptop  ──►  browser → http://cms.newsroom.lan
├── Reporter laptop ──►  same
└── Staff phones     ──►  same

Guest Wi‑Fi: isolated — cannot reach the host
Field / cafe / another office: Tailscale only (Part 8)
```

- **PostgreSQL** = where data lives (never exposed to phones or other laptops).
- **PM2** = what keeps apps running on the host.
- **Caddy** = the one HTTP(S) door on the staff Wi‑Fi.
- **Tailscale** = how people work securely when they are not on that Wi‑Fi.

Add Caddy when more than one device in the room should open the apps. Add Tailscale when anyone leaves the building.

---

## Part 1 — Why local PostgreSQL?

A small newsroom does not need a paid cloud database to run internal tools. A single Postgres server on the **host** (the always-on desk machine) gives you:

- **In-house** — drafts, sources, and app data stay on a machine you control.
- **Free & fast** — no network latency, no usage limits.
- **One server, many apps** — each app gets its own database, fully isolated, zero conflicts.
- **Real Postgres** — same engine as Supabase/Neon/Railway, so moving a *public* product to production later is easy.

> **Important limitation:** this database only listens on `localhost`. Staff browsers never connect to it. It is **not** reachable from the internet or from an app deployed on Replit/a VPS. Use it for tools that run on the newsroom host. For a public site that readers use, get a hosted DB (Supabase, Neon, Railway).

### Setup — macOS (Apple Silicon)

```bash
# 1. Install Postgres (Homebrew)
brew install postgresql@16

# 2. Make it always-on (starts on login, restarts on crash)
brew services start postgresql@16

# 3. Put Postgres 16 on your PATH
echo 'export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

# 4. Create one database per app
createdb cms
createdb archive
createdb bot
```

By default Homebrew's Postgres uses your macOS username with **no password** for local connections — perfect for development.

Handy commands (macOS):

```bash
brew services list        # is Postgres running?
psql -l                   # list all databases
psql cms                  # open a database
createdb my_new_app       # add a database for a new app
```

### Setup — Windows

Same idea: one always-on Postgres service, one database per app.

```powershell
# 1. Install Postgres 16 (pick one)
winget install PostgreSQL.PostgreSQL.16
# or download the installer: https://www.postgresql.org/download/windows/

# During install: set a password for the `postgres` user and keep port 5432.
# Add "Command Line Tools" / bin to PATH when the installer offers it
# (or add C:\Program Files\PostgreSQL\16\bin manually).

# 2. Confirm the Windows service is running
# Services app → "postgresql-x64-16" → Running
# Or in an elevated PowerShell:
Get-Service -Name postgresql*

# 3. Open a new terminal, then create one database per app
createdb -U postgres cms
createdb -U postgres archive
createdb -U postgres bot
# You'll be prompted for the postgres password you set at install.
```

Handy commands (Windows):

```powershell
psql -U postgres -l              # list databases
psql -U postgres -d cms          # open a database
createdb -U postgres my_app      # new app DB
```

> **Windows note:** the installer usually creates a `postgres` superuser **with a password** (unlike Homebrew on Mac, which is often passwordless for your OS user). Use that password in `DATABASE_URL`.

### Connect an app

Put this in each app’s `.env` **on the host** (pointing at that app’s own database):

**macOS (typical):**
```
DATABASE_URL="postgresql://<your-mac-username>@localhost:5432/cms"
```

**Windows (typical):**
```
DATABASE_URL="postgresql://postgres:<your-password>@localhost:5432/cms"
```

That's it — the app on the host talks to its own database. Staff devices never see this URL.

---

## Part 2 — Why PM2?

During development you run apps with `npm run dev`. But that dies the moment you close the terminal. PM2 fixes this:

- **Keeps apps running in the background** — close the terminal, they stay up.
- **Auto-restart on crash** — if a bot throws, PM2 brings it back.
- **Runs many apps at once** — web apps *and* bots, all managed together on the host.
- **One command to rule them all** — start / reload / stop everything at once.
- **Survives reboot** (once configured) — apps come back after you restart the computer.

### Setup

```bash
# Install PM2 globally
npm install -g pm2
```

Each app describes its processes in an `ecosystem.config.cjs` file. Example (a web app + a Telegram bot):

```javascript
// ecosystem.config.cjs
const path = require("path");

module.exports = {
  apps: [
    {
      name: "cms-web",
      cwd: __dirname,
      script: "npm",
      args: "run start",       // or "run dev"
      env: { NODE_ENV: "production", PORT: 4783 },
      autorestart: true,
      max_restarts: 10,
    },
    {
      name: "cms-telegram",
      cwd: __dirname,
      script: path.join(__dirname, "node_modules/.bin/tsx"),
      args: "telegram/bot.ts",
      interpreter: "none",
      autorestart: true,
      max_restarts: 10,
    },
  ],
};
```

### Run one app

```bash
pm2 start ecosystem.config.cjs   # start this app's processes
pm2 reload all                   # reload after code changes
pm2 stop all                     # stop
pm2 list                         # see what's running
pm2 logs                         # tail logs
```

### Run *all* your apps with one command

If you run several apps on the host, add a tiny workspace script so you can control them all at once.

#### macOS / Git Bash / WSL — `scripts/pm2-all.sh`

```bash
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"

ECOSYSTEMS=(
  "$ROOT/cms/ecosystem.config.cjs"
  "$ROOT/archive/ecosystem.config.cjs"
  "$ROOT/bot/ecosystem.config.cjs"
)

case "${1:-}" in
  start)
    for eco in "${ECOSYSTEMS[@]}"; do pm2 startOrReload "$eco" --update-env; done
    pm2 save && pm2 list ;;
  reload) pm2 reload all --update-env && pm2 list ;;
  stop)   pm2 stop all && pm2 list ;;
  status) pm2 list ;;
  *) echo "Usage: $0 <start|reload|stop|status>"; exit 1 ;;
esac
```

Root `package.json` (macOS / Git Bash):

```json
{
  "scripts": {
    "pm2:start":  "bash scripts/pm2-all.sh start",
    "pm2:reload": "bash scripts/pm2-all.sh reload",
    "pm2:stop":   "bash scripts/pm2-all.sh stop",
    "pm2:status": "bash scripts/pm2-all.sh status"
  }
}
```

#### Windows PowerShell — `scripts/pm2-all.ps1`

```powershell
param(
  [Parameter(Mandatory = $true)]
  [ValidateSet("start", "reload", "stop", "status")]
  [string]$Action
)

$Root = Split-Path -Parent $PSScriptRoot
$Ecosystems = @(
  "$Root\cms\ecosystem.config.cjs",
  "$Root\archive\ecosystem.config.cjs",
  "$Root\bot\ecosystem.config.cjs"
)

switch ($Action) {
  "start" {
    foreach ($eco in $Ecosystems) { pm2 startOrReload $eco --update-env }
    pm2 save
    pm2 list
  }
  "reload" { pm2 reload all --update-env; pm2 list }
  "stop"   { pm2 stop all; pm2 list }
  "status" { pm2 list }
}
```

Root `package.json` (Windows):

```json
{
  "scripts": {
    "pm2:start":  "powershell -ExecutionPolicy Bypass -File scripts/pm2-all.ps1 start",
    "pm2:reload": "powershell -ExecutionPolicy Bypass -File scripts/pm2-all.ps1 reload",
    "pm2:stop":   "powershell -ExecutionPolicy Bypass -File scripts/pm2-all.ps1 stop",
    "pm2:status": "powershell -ExecutionPolicy Bypass -File scripts/pm2-all.ps1 status"
  }
}
```

Same daily commands on both OSes (from the host’s project folder):

```bash
pnpm run pm2:start     # start every app
pnpm run pm2:reload    # after you change code
pnpm run pm2:stop      # stop everything
pnpm run pm2:status    # see everything
```

### Survive a reboot (one-time)

```bash
pnpm run pm2:start
pm2 save            # remember the current app list
pm2 startup         # prints an OS-specific command — run it once
```

- **macOS:** `pm2 startup` prints a `sudo ... launchd ...` command — paste and run it.
- **Windows:** `pm2 startup` prints instructions to install a Windows startup/service hook — run what it prints (often needs an **Administrator** terminal). Then `pm2 save` again if asked.

After this: closing the terminal keeps apps running, and a reboot restores them automatically.

### Daily workflow after you change code

```bash
pnpm run pm2:reload
```

> Apps running a **production** build (`next start`) need a rebuild first: `npm run build` in that project, then reload. Apps in **dev** mode reload as-is.

---

## Part 3 — PM2 vs Docker: what's the difference?

Both keep apps running, but they solve different problems.

| | **PM2** | **Docker** |
|---|---|---|
| What it is | A process manager for Node apps | A container platform that packages the whole environment |
| What it manages | Your running Node processes | Isolated containers (OS libs, runtime, app, sometimes the DB too) |
| Setup effort | `npm i -g pm2`, one config file | Dockerfiles, images, volumes, networks, compose files |
| Resource use | Very light | Heavier (each container ships its own environment) |
| Isolation | Shares your machine's Node/OS | Fully isolated per container |
| Best for | Always-on apps on one newsroom host | Reproducible deploys, larger teams, complex multi-service systems |
| Learning curve | Minutes | Hours/days |

### Why we chose PM2 for a small newsroom

The stack is **browser-based internal apps + bots** on **one dedicated host** that stays in the office. Staff open them from their own laptops and phones. For that, PM2 is the right tool:

1. **Simplicity** — one config file per app, one command to run them all. No Dockerfiles, images, or volumes to maintain.
2. **Lightweight** — the apps run directly on the host’s Node. No container overhead eating RAM/CPU.
3. **Fast iteration** — change code, `pnpm run pm2:reload`, done. No rebuilding images.
4. **Shared local Postgres** — every app just points `DATABASE_URL` at `localhost:5432`. No container networking to wire up.
5. **Good enough uptime** — auto-restart on crash + restore on reboot is what a small newsroom needs on one machine.

**When we'd switch to Docker:** when an app goes to production on a public server, needs to run identically across many machines, or has many interdependent services. Docker shines for *deployment and reproducibility*; PM2 shines for *simple always-on on one host*. Keep this stack private (LAN + Tailscale) until you actually ship something to readers.

---

## Part 4 — Staff laptops and phones (same Wi‑Fi)

PostgreSQL stays on `localhost` on the **host**. Editors’ and reporters’ laptops and phones never connect to it. They open the apps in a browser through **Caddy**, which is the only process listening on the staff network.

```text
Staff laptop / staff phone (browser)
      ↓
cms.newsroom.lan / archive.newsroom.lan  →  host LAN IP
      ↓
Caddy (:80 or :443)
      ↓
127.0.0.1:<port-a>  |  127.0.0.1:<port-b>
      ↓
PM2-managed Node apps
      ↓
PostgreSQL (localhost only)
```

Node apps listen on **127.0.0.1** only. **Caddy** is the single LAN entry point.

Example URLs (after DNS is configured):

| App (example) | URL |
|---------------|-----|
| CMS | `http://cms.newsroom.lan` |
| Archive | `http://archive.newsroom.lan` |

Use a **private** suffix such as `newsroom.lan`. A home office can use `home.arpa` the same way. Do not invent fake public domains (`.com` names you do not own).

Prefer a **desk machine that stays in the newsroom**, plugged in, lid closed is fine. Do not run Postgres + Caddy on a laptop that leaves for interviews — every staff URL would die when it does.

### Caddy on the host (short version)

Install Caddy (macOS: `brew install caddy`; Windows: `winget install Caddy.Caddy`). Point each hostname at the local PM2 port:

```
# Caddyfile
cms.newsroom.lan {
  reverse_proxy 127.0.0.1:4783
}

archive.newsroom.lan {
  reverse_proxy 127.0.0.1:4784
}
```

Start Caddy as a service (`brew services start caddy` on macOS). Apps keep listening on localhost; only Caddy is reachable from staff Wi‑Fi.

HTTP on a locked **staff** SSID is workable if every app has a login. For unpublished work, prefer HTTPS (Caddy `tls internal` if you can install the local CA on staff devices, or Tailscale Serve in Part 8, which gives HTTPS without touching the router).

---

## Part 5 — Fix the host machine’s LAN IP (DHCP reservation)

Before adding DNS records, give the host a **stable IP** on the newsroom (or home-office) network. Otherwise the router may assign a new address after reboot and every staff bookmark breaks.

### Find current IP and hardware address

**macOS**

```bash
ipconfig getifaddr en0
```

Example result: `192.168.1.42` — use **your** value everywhere below as `<HOST_LAN_IP>`. If the host is on Ethernet, use that adapter instead of `en0`.

Wi‑Fi hardware address (for the reservation):

```bash
networksetup -getmacaddress Wi-Fi
```

Or: **System Settings → Network → Wi‑Fi → Details → Hardware address**.

**Windows**

```powershell
ipconfig
Get-NetAdapter | Select-Object Name, MacAddress
```

Use the IPv4 address of the active Wi‑Fi or Ethernet adapter as `<HOST_LAN_IP>`.

### Add reservation in the router

1. Open the router admin page (common addresses: `192.168.1.1`, `192.168.0.1`, `192.168.1.254`, or check the router label / ISP app).
2. Log in.
3. Find a section named something like:
   - **DHCP Reservation**
   - **Address reservation**
   - **Static lease**
   - **Reserved IP**
4. Add an entry:
   - **Device:** the host computer (from connected devices), or paste its **Wi‑Fi / Ethernet MAC address**
   - **IP:** `<HOST_LAN_IP>` (the address you looked up above)
5. Save / Apply.

Optional — renew DHCP on the host:

**macOS**

```bash
sudo ipconfig set en0 DHCP
ipconfig getifaddr en0   # should still match <HOST_LAN_IP>
```

**Windows**

```powershell
ipconfig /renew
ipconfig
```

---

## Part 6 — DNS so every staff device can resolve hostnames

Editing `/etc/hosts` (or the Windows hosts file) on the **host** does **not** affect anyone else. Every staff laptop and phone must resolve `cms.newsroom.lan` to `<HOST_LAN_IP>` via **router DNS** (or another DNS server you control).

### Option A — Router local DNS (recommended)

In the router admin, find a section named something like:

- **Local DNS**
- **DNS hostnames**
- **Host overrides**
- **Static DNS**
- **Local domain**

Add one record per app (all point at the **same** host IP):

| Hostname | IP |
|----------|-----|
| `cms.newsroom.lan` | `<HOST_LAN_IP>` |
| `archive.newsroom.lan` | `<HOST_LAN_IP>` |

Save. Reboot the router if prompted.

**On each staff laptop and phone:** use **automatic DNS** for the **staff** Wi‑Fi (the router). If a device uses **manual** public DNS (e.g. 1.1.1.1, 8.8.8.8), local records are skipped — switch back to automatic or it will not resolve `.newsroom.lan`.

Toggle Wi‑Fi off/on, then open a browser: `http://cms.newsroom.lan`.

### Option B — dnsmasq on the host

If the router **cannot** add host records but **can** set a custom DNS server for DHCP clients:

**macOS**

```bash
brew install dnsmasq
echo "address=/newsroom.lan/$(ipconfig getifaddr en0)" >> /opt/homebrew/etc/dnsmasq.conf
sudo brew services start dnsmasq
```

In the router DHCP settings, set **DNS server** to `<HOST_LAN_IP>` for the **staff** network only. That maps `*.newsroom.lan` to the host. Do not point the **guest** network at this DNS if guests must stay isolated.

### Option C — Host machine only (`/etc/hosts`)

For testing **on the host itself** (not on staff phones or other laptops):

```bash
# Add to /etc/hosts (requires password) — macOS / Linux
<HOST_LAN_IP> cms.newsroom.lan archive.newsroom.lan
```

Windows equivalent: `C:\Windows\System32\drivers\etc\hosts` (run Notepad as Administrator).

---

## Part 7 — Verify

**Host — apps running:**

```bash
pm2 status
brew services list | grep caddy
curl -sI http://cms.newsroom.lan | head -1
```

**Host — DNS (router working):**

```bash
dig cms.newsroom.lan @192.168.1.1 +short
```

Should print `<HOST_LAN_IP>`. Use your router’s IP if not `192.168.1.1`.

**A staff laptop or phone:**

1. On the **staff** Wi‑Fi (not guest).
2. Host awake; PM2 + Caddy running.
3. Browser → `http://cms.newsroom.lan`.
4. Repeat on a second laptop and a phone so you know DNS is not a one-device fluke.

**Troubleshooting**

| Problem | Likely cause |
|---------|----------------|
| Host works, staff device “server not found” | Router DNS not configured; or the device is on Manual public DNS |
| Visitor on guest Wi‑Fi cannot open the URL | Expected — guest isolation should block the host |
| Staff device cannot reach host, DNS is correct | Client isolation / AP isolation on the **staff** SSID — turn **that** off; keep it **on** for guest |
| Connection refused | Caddy not running; OS firewall blocking Caddy |
| 502 Bad Gateway | PM2 app offline — check `pm2 status` |
| Worked yesterday, broken today | Host got a new IP — fix DHCP reservation |

---

## Part 8 — Security (staff LAN + Tailscale)

A small newsroom is not a public website, but it still holds unpublished copy. Treat the staff Wi‑Fi as a **trusted room**, not as the internet — and do not let visitors into that room.

### Staff Wi‑Fi vs guest Wi‑Fi

- **Two SSIDs.** Staff network for newsroom devices only. **Guest** network for sources, freelancers you have not onboarded, and visitors.
- **Isolate guests.** Guest Wi‑Fi must use **client isolation** (or a separate VLAN) so it **cannot** reach `<HOST_LAN_IP>` or other staff machines.
- **Do not isolate staff** from the host. If “AP isolation” / “client isolation” is on for the staff SSID, laptops and phones will not reach Caddy. Turn isolation **off** on staff, **on** on guest.
- **WPA2 or WPA3**, a password you do not print on the wall. Rotate it when someone leaves or a device is lost.
- Never give the staff password to a visitor “just for a minute.”

### What stays closed

- **No port-forward** of 80, 443, 5432, or app ports on the router. Staff in the room use the LAN. Everyone else uses Tailscale.
- **PostgreSQL stays on localhost.** Browsers talk to the app; the app talks to Postgres. Do not bind Postgres to `0.0.0.0`.
- Node apps listen on **127.0.0.1**; only Caddy (or Tailscale Serve) is reachable from other devices.
- **LAN is not a login.** Anyone on the staff SSID who can resolve `cms.newsroom.lan` can hit Caddy. Every app needs its own authentication. Do not share those URLs as if they were public.
- Prefer **HTTPS** for anything with drafts or sources (Tailscale Serve, or Caddy `tls internal` plus a trusted local CA on staff devices).

### Host machine

- Dedicated, stays in the office, disk encryption on, screen locked, OS and Caddy/PM2 updated.
- Back up Postgres (and know how to restore). A stolen or dead host is a newsroom outage.
- OS firewall: allow Caddy on the staff LAN if you use Part 4; allow Tailscale; **deny inbound 5432** from everywhere.

### Tailscale — field, cafes, and extra lock-down

[Tailscale](https://tailscale.com) is a WireGuard mesh VPN. Install it on the **host** and on every **staff** laptop and phone you trust. People can reach the apps **without opening the router**, including from the field, another office, or a cafe.

**Why it fits a newsroom**

- No router holes. Postgres still never leaves localhost.
- Reporters away from the staff Wi‑Fi still work.
- MagicDNS stays stable even if the office LAN IP changes.
- You can skip exposing Caddy to the whole LAN and serve **only** over Tailscale if the office Wi‑Fi is shared or untrusted.

**Practical setup**

1. One Tailscale account for the newsroom (SSO / **2FA** on). One admin who can approve devices.
2. Install Tailscale on the host and on each staff laptop and phone; sign in to the **same** tailnet.
3. Turn on **MagicDNS** in the Tailscale admin console.
4. Prefer **Tailscale Serve** (HTTPS) or bind Caddy only to the Tailscale interface.
5. Leave **Funnel** off. Funnel publishes to the internet; that is the opposite of this guide.
6. Turn on **device approval**. A new install cannot reach the host until an admin allows it.
7. Use **ACLs** (and tags) when you have more than a few people: e.g. `tag:host` can be reached by `tag:staff`; contractors get a narrower tag or none.

Example: from a staff phone off-site, open `http://<host-magicdns-name>` (or the Serve URL Tailscale prints). You do not need `.newsroom.lan` or router DNS for that path.

**Offboarding:** when a laptop is lost, a phone is stolen, or someone leaves, **revoke that device** in the Tailscale admin console the same day. Rotate the staff Wi‑Fi password if they had it.

**Split the two modes**

| Situation | Use |
|-----------|-----|
| In the newsroom, on staff Wi‑Fi | Caddy + `*.newsroom.lan` + router DNS |
| Field, cafe, home, or you don’t trust this Wi‑Fi | Tailscale only |
| Visitors / sources | Guest Wi‑Fi only — no Tailscale, no staff SSID |
| Never | Router port-forward of 80 / 443 / 5432 / app ports |

**Hardening extras**

- Keep Tailscale updated on every device.
- Don’t advertise Postgres or PM2 ports on Tailscale — only the HTTP(S) entry (Caddy or Serve).
- If you use Tailscale **subnet routing** so clients can reach the whole office LAN, lock it down with ACLs; a full-LAN route is broader than serving one hostname. Prefer Serve / one hostname.
- Contractors and interns: guest Wi‑Fi until you add their device to the tailnet and the staff SSID on purpose.

---

## TL;DR

**On the always-on host (macOS)**
```bash
brew install postgresql@16 && brew services start postgresql@16
npm install -g pm2
createdb cms
# DATABASE_URL="postgresql://<mac-username>@localhost:5432/cms"
```

**On the always-on host (Windows)**
```powershell
winget install PostgreSQL.PostgreSQL.16
npm install -g pm2
createdb -U postgres cms
# DATABASE_URL="postgresql://postgres:<password>@localhost:5432/cms"
```

**Every day (host)**
```bash
pnpm run pm2:start     # bring everything online
pnpm run pm2:reload    # after code changes
pnpm run pm2:status    # check health
```

**Staff laptops and phones on newsroom Wi‑Fi:** DHCP-reserve the host IP, add `*.newsroom.lan` on the router, put Caddy in front of PM2. Staff SSID (no guest isolation toward the host); **guest SSID isolated**. Do not port-forward. Apps require login.

**Away from the office:** Tailscale on host + staff devices (MagicDNS / Serve, Funnel off, device approval, revoke on exit). Postgres stays on localhost.

Private, always-on, one host. Move to a hosted DB + Docker only when you ship something public to readers.
