An RSS manager, allowing for publishing to a set of managed RSS feeds.
- Node.js 22 — the version is pinned in .nvmrc, and
package.jsonrequires>=22.0.0. - make — every workflow is wrapped in a target. Run
makeormake helpto list them. - A C toolchain, since
sqlite3builds a native binding. macOS needs the Xcode command line tools (xcode-select --install); Debian/Ubuntu needsbuild-essentialandpython3.
make setupThis installs nvm if it is missing, installs the Node version from
.nvmrc, and runs npm install. If you already manage Node yourself,
make deps just does the npm install.
Then create your local environment file:
cp .env.example .envThe Makefile auto-loads .env and exports it into every target, so anything
you put there applies to make run, make test, and friends.
All configuration is read from the environment in src/lib/config.ts:
| Variable | Default | Purpose |
|---|---|---|
HOST |
0.0.0.0 |
Interface the server binds |
PORT |
3000 |
Port the server binds to |
NODE_ENV |
development |
Environment name |
DATABASE_FILE_PATH |
database.sqlite |
SQLite file, made on boot |
In production the systemd unit sets HOST=127.0.0.1, so the app is reachable
only through nginx and never directly from the internet.
.env and *.sqlite are both gitignored, so your local database and secrets
stay out of commits.
make runRuns src/main.ts directly through tsx — no build step, no
separate compile process. The server logs its URL on boot:
Server is running on http://0.0.0.0:3000
Verify it with curl localhost:3000, which hits the route defined in
src/controllers/index.ts.
make run does not reload on file changes; restart it after an edit. To
type-check continuously in a second terminal, use make watch
(tsc --watch).
To exercise the compiled output the way production does:
make build && npm startmake clean removes build/ and tmp/ when stale artifacts pile up.
make testRuns Vitest once. For a red-green loop while you work,
use npm run test:watch. Tests live next to the code they cover as
*.test.ts — see src/lib/config.test.ts.
make check # ESLint, tsc --noEmit, Prettier check, markdownlint
make fix # auto-fix all of the above
make format # Prettier onlymake check runs the linters serially and fails on the first error. Run it
before pushing — CI runs the same targets, and the formatting job fails if
make format produces a diff.
Routes are grouped into controllers. A controller declares a basePath and a
list of routes, and handlers receive a request carrying the shared
Dependencies object (the database handle today) on
req.deps:
const FeedsController: Controller<Dependencies> = {
basePath: "/feeds",
routes: [
{
path: "/",
method: "GET",
handler: async (req, res) => {
const db = req.deps.db.instance;
res.json(await db.all("SELECT * FROM feeds"));
},
},
],
};Register it by adding it to the controller list in src/main.ts.
src/
main.ts # Entrypoint: config, DB, controllers, listen
controllers/ # Route definitions, one controller per resource
lib/
config.ts # Environment parsing
database.ts # SQLite connection
dependencies.ts # The dependency bag handed to every handler
rest/ # Thin typed layer over Express
Two workflows run on pull requests to main:
- check.yml —
make deps,make format(must produce no diff),make check - ci.yml —
make deps,make build,make test
Running make fix && make check && make test locally covers everything both
workflows do.
One Linux box runs two things: systemd supervises the Node process on
127.0.0.1:3000, and nginx serves port 80 and proxies to it. Nothing else —
no containers, no process manager.
- Debian/Ubuntu with systemd
nginxpython3-certbot-nginxgit,make, and a C toolchain (build-essential,python3) for thesqlite3native bindingnvm, or the Node version from .nvmrc installed system-wide. A non-interactive SSH shell never sourcesnvm.sh, so theMakefilelooks under$NVM_DIR/versions/nodewhennodeis offPATHand pins the absolute path it finds into the systemd unit.- A deploy user with
NOPASSWDsudo, so CD can write/etcand restart units
Nothing else needs setting up by hand — the deploy clones the repo and installs
the Node version itself. To see what a host is still missing before deploying
to it, run make deploy/doctor; every failing check prints its own fix.
Both live in etc/ and are templates — @PLACEHOLDER@ tokens are
substituted from the Makefile variables at install time, so the port and
server name have exactly one source of truth.
- etc/nginx/spigot.conf →
/etc/nginx/sites-available/spigot.conf, symlinked intosites-enabled/(the stockdefaultsite is removed, since it also claims port 80). Installed when no certificate exists yet: port 80 only, proxying directly. - etc/nginx/spigot-tls.conf → the same
destination, installed instead once
/etc/letsencrypt/live/$SERVER_NAME/holds a certificate: port 80 redirects, port 443 proxies. - etc/nginx/snippets/spigot-proxy.conf
→
/etc/nginx/snippets/spigot-proxy.conf, theproxy_passbody both of the above include, so the proxy is defined once. - etc/systemd/spigot.service →
/etc/systemd/system/spigot.service - etc/systemd/spigot.env.example →
/etc/spigot/spigot.env, only if that file does not exist yet
If nginx -t rejects a freshly installed config, the previous files are
restored and nginx is never reloaded, so a bad deploy cannot take the site
down.
/etc/spigot/spigot.env is for host-specific overrides and secrets. Deploys
never overwrite it.
make deployRun on the server, in order:
- Sync — clones the repo if
$(DEPLOY_DIR)has no checkout, otherwise fetches and hard-resets it toorigin/main. - Node — sources
nvm.shand runsnvm install && nvm use, so the version in .nvmrc is present before anything needs it. - Re-exec —
makere-invokes itself in the deploy directory, so the rest of the run uses the Makefile that was just pulled and re-resolves the Node path that step 2 may have just created. - Check —
deploy/doctor, now against the synced tree and installed Node. - Release —
npm ci && npm run build, install both config files, reload nginx, restartspigot.service, then confirm the unit is actually active, dumping the last 50 journal lines and failing if it is not.
Useful overrides:
make deploy SERVER_NAME=spigot.example.com APP_PORT=3000
make deploy/release # rebuild and reinstall without pulling
make deploy/status # unit status plus recent logs
make deploy/logs # journalctl -fcd.yml runs on every push to main: it loads the
deploy key, then SSHes in and runs make deploy. All of the deploy logic lives
in the Makefile, so it behaves identically by hand.
Repository secrets: SSH_PRIVATE_KEY, SSH_USER, SSH_HOST, and
SSH_KNOWN_HOSTS (the output of ssh-keyscan <host>; without it the workflow
falls back to trusting whatever key the host presents).
Repository variables: DEPLOY_DIR (default /srv/spigot), SERVER_NAME,
APP_PORT.
This repo owns the nginx config; certbot owns only the certificate. That split
is deliberate: certbot --nginx rewrites the installed site file, which the
next deploy would overwrite and break TLS. So certbot runs in certonly --webroot mode instead, answering challenges from /var/www/certbot — which
both site configs serve — and never touching nginx config at all.
Once DNS points at the box:
make deploy/tls SERVER_NAME=spigot.example.com CERTBOT_EMAIL=you@example.comThat installs the HTTP config, issues the certificate, then reinstalls as
HTTPS. Every later make deploy sees the certificate and keeps serving the
TLS config, so the switch survives deploys with nothing further to do.
Renewal is certbot's own systemd timer. The --deploy-hook registered at
issuance reloads nginx after each renewal.
Migrating a certificate first issued with certbot --nginx: the
certificate itself is fine and gets picked up on the next deploy, but its
renewal config still names the nginx authenticator and installer, so renewals
would keep editing nginx. Certbot only rewrites that config when it actually
issues, so force one reissue:
make deploy/tls SERVER_NAME=spigot.example.com CERTBOT_EMAIL=you@example.com \
CERTBOT_FORCE=1Confirm with sudo certbot certificates and check that
/etc/letsencrypt/renewal/spigot.example.com.conf now reads
authenticator = webroot with no installer = nginx.