UPDATED: Local development setup guide for newsrooms that want to be local first
AI For Newsroom
How a small newsroom can run internal apps on one always-on host, share them with staff laptops and phones on office Wi-Fi, and reach them with Tailscale in the field — without a cloud bill or opening the internet.
Published Aug 31, 2026
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)
# 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):
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.
# 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):
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
postgressuperuser with a password (unlike Homebrew on Mac, which is often passwordless for your OS user). Use that password inDATABASE_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
# 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):
// 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
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
#!/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):
{
"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
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):
{
"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):
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)
pnpm run pm2:start
pm2 save # remember the current app list
pm2 startup # prints an OS-specific command — run it once
- macOS:
pm2 startupprints asudo ... launchd ...command — paste and run it. - Windows:
pm2 startupprints instructions to install a Windows startup/service hook — run what it prints (often needs an Administrator terminal). Thenpm2 saveagain if asked.
After this: closing the terminal keeps apps running, and a reboot restores them automatically.
Daily workflow after you change code
pnpm run pm2:reload
Apps running a production build (
next start) need a rebuild first:npm run buildin 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:
- Simplicity — one config file per app, one command to run them all. No Dockerfiles, images, or volumes to maintain.
- Lightweight — the apps run directly on the host’s Node. No container overhead eating RAM/CPU.
- Fast iteration — change code,
pnpm run pm2:reload, done. No rebuilding images. - Shared local Postgres — every app just points
DATABASE_URLatlocalhost:5432. No container networking to wire up. - 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.
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
ipconfig getifaddr en0
Example result: 192.168.1.42 — use your value everywhere below as <HOSTLANIP>. If the host is on Ethernet, use that adapter instead of en0.
Wi‑Fi hardware address (for the reservation):
networksetup -getmacaddress Wi-Fi
Or: System Settings → Network → Wi‑Fi → Details → Hardware address.
Windows
ipconfig
Get-NetAdapter | Select-Object Name, MacAddress
Use the IPv4 address of the active Wi‑Fi or Ethernet adapter as <HOSTLANIP>.
Add reservation in the router
- 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). - Log in.
- Find a section named something like:
- DHCP Reservation
- Address reservation
- Static lease
- Reserved IP
- Add an entry:
- Device: the host computer (from connected devices), or paste its Wi‑Fi / Ethernet MAC address
- IP:
<HOSTLANIP>(the address you looked up above)
- Save / Apply.
Optional — renew DHCP on the host:
macOS
sudo ipconfig set en0 DHCP
ipconfig getifaddr en0 # should still match <HOST_LAN_IP>
Windows
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 <HOSTLANIP> 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 | <HOSTLANIP> | | archive.newsroom.lan | <HOSTLANIP> |
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
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 <HOSTLANIP> 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):
# 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:
pm2 status
brew services list | grep caddy
curl -sI http://cms.newsroom.lan | head -1
Host — DNS (router working):
dig cms.newsroom.lan @192.168.1.1 +short
Should print <HOSTLANIP>. Use your router’s IP if not 192.168.1.1.
A staff laptop or phone:
- On the staff Wi‑Fi (not guest).
- Host awake; PM2 + Caddy running.
- Browser →
http://cms.newsroom.lan. - 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
<HOSTLANIP>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.lancan 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 internalplus 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 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
- One Tailscale account for the newsroom (SSO / 2FA on). One admin who can approve devices.
- Install Tailscale on the host and on each staff laptop and phone; sign in to the same tailnet.
- Turn on MagicDNS in the Tailscale admin console.
- Prefer Tailscale Serve (HTTPS) or bind Caddy only to the Tailscale interface.
- Leave Funnel off. Funnel publishes to the internet; that is the opposite of this guide.
- Turn on device approval. A new install cannot reach the host until an admin allows it.
- Use ACLs (and tags) when you have more than a few people: e.g.
tag:hostcan be reached bytag: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)
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)
winget install PostgreSQL.PostgreSQL.16
npm install -g pm2
createdb -U postgres cms
# DATABASE_URL="postgresql://postgres:<password>@localhost:5432/cms"
Every day (host)
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.