Bun-powered service that ingests typed content (starting with quotes), stores it in SQLite, renders OG assets, and serves embeds, Markdown, and RSS feeds from a single VPS-friendly deployment.
- Bun 1.3+
bun install
bun run migrate # creates SQLite schema under ./data
bun run key:create my-key # prints a new API token (store it somewhere safe)Tip: When you run Bun commands as another user (for example via
sudo -u quote-cards), start a login shell (bash -lc) or setPATH="$HOME/.bun/bin:$PATH"inside the command sobunresolves.
The service stores data under ./data by default (configurable via DATA_ROOT). Generated assets land in:
data/og/<type>/<id>.jpgdata/embed/<type>/<id>.htmldata/markdown/<type>/<id>.mddata/rss/<type>.xml
Start the HTTP API and render worker in separate terminals:
bun run start:api
bun run start:workerEnvironment variables:
PORT– API port (default3000).DATA_ROOT– absolute/relative path for asset output (./datadefault).DATABASE_PATH– custom SQLite file path.SITE_ORIGIN– public hostname (used when generating absolute asset URLs/RSS links).CARD_VERSION– cache-busting query appended to OG JPEG URLs.
Use HEAD /items to validate API tokens without mutating data:
curl -I http://localhost:3000/items \
-H "Authorization: Bearer <token>"curl -X POST http://localhost:3000/items \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"type": "quote",
"attributes": {
"quote_text": "What is an API.",
"author": "Anot Person",
"url": "https://example.com/static-first-two"
},
"tags": ["static", "inspiration"]
}'The API enqueues the new item (render_status="queued"). The worker renders outputs, regenerates the quote RSS feed, and updates the database to render_status="rendered" once complete.
Prefer a browser? When the API is running locally, visit http://localhost:3000/ (or the host/IP where it’s exposed) to use the built-in form. Enter your token once; the page stores it in local storage so you can submit quotes quickly from any device on your network.
Reminder: Every API request header must be
Authorization: Bearer <token>(note the literalBearerprefix).
GET /items?type=quote– list items (supportslimit,cursor,tag).GET /items/<id>– retrieve metadata and asset URLs.GET /items/<id>/markdown– download canonical Markdown for CLI/editor workflows.
Public assets resolve relative to /og/, /embed/, /markdown/, and /rss/. Set SITE_ORIGIN so RSS links and returned URLs match your deployment.
GET /– browser form for capturing quotes (stores the API token locally).GET /feed– HTML feed with the 50 most recent rendered quotes.GET /about– static “about” page for copy and contact info.GET /rss/quote.xml– RSS feed regenerated after each successful render.
All pages share public/assets/base.css; tweak styling there to update the entire surface.
Run the bundled smoke test to sanity-check the running service (set BASE_URL and API_TOKEN if you’re pointing at a remote instance):
BASE_URL=http://localhost:3000 bun run smokeIf API_TOKEN is provided, the script also exercises the authenticated HEAD /items probe.
Tokens are pre-generated secrets hashed in the database. Use bun run key:create <name> to mint a new one. The script prints the plaintext token once—store it securely. To rotate keys, delete the row from api_keys (via SQLite shell) and re-run the script.
- Provision a small Linux VPS (e.g., Ubuntu 22.04, 1 vCPU/1 GB RAM). Allow inbound 80/443 (and 22 for SSH).
- Install prerequisites and Bun:
sudo apt update sudo apt install -y git curl sqlite3 libfontconfig1 nginx curl -fsSL https://bun.sh/install | bash echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.profile
- Create a dedicated user and directories for the app and data:
sudo useradd --system --home /srv/quote-cards --shell /usr/sbin/nologin quote-cards sudo mkdir -p /srv/quote-cards/{app,data,logs} sudo chown -R quote-cards:quote-cards /srv/quote-cards - Deploy the code and install dependencies:
sudo -u quote-cards -H bash -c 'cd /srv/quote-cards && git clone https://github.com/huugof/quuote.git app' sudo -u quote-cards -H bash -c 'export PATH="$HOME/.bun/bin:$PATH"; cd /srv/quote-cards/app && bun install'
- Run migrations and mint an API token (store the plaintext token securely):
The
sudo -u quote-cards -H bash -c 'export PATH="$HOME/.bun/bin:$PATH"; cd /srv/quote-cards/app && bun run migrate' sudo -u quote-cards -H bash -c 'export PATH="$HOME/.bun/bin:$PATH"; set -a; source /etc/quote-cards.env; set +a; cd /srv/quote-cards/app && bun run key:create admin'
set -a; source …; set +asequence exports the same environment variables systemd uses so the key lands in/srv/quote-cards/data/db.sqlite. If/etc/quote-cards.envis strictly600 root:root, either temporarilychmod 640 /etc/quote-cards.env(and restore600afterward) or export the variables inline instead of sourcing. - Create
/etc/quote-cards.env(permission600) with production env vars:PORT=3000 DATA_ROOT=/srv/quote-cards/data DATABASE_PATH=/srv/quote-cards/data/db.sqlite SITE_ORIGIN=https://quotes.example.com CARD_VERSION=1 LOG_LEVEL=info - Ensure the logs directory is owned by the service user:
sudo mkdir -p /srv/quote-cards/logs sudo chown quote-cards:quote-cards /srv/quote-cards/logs
- Install the provided systemd units and start the services:
The units call
sudo cp deploy/systemd/quote-cards-*.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now quote-cards-worker.service quote-cards-api.service
/usr/bin/env bun. If Bun only exists at/srv/quote-cards/.bun/bin/bun, either update eachExecStartto that absolute path or addEnvironment=PATH=/srv/quote-cards/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bininside the[Service]block before reloading systemd. - Configure a reverse proxy (Caddy/Nginx) to terminate TLS and forward to
localhost:3000. The sample Nginx file underdeploy/nginx/is a good starting point—if you want/api/...paths to reach Bun’s/itemsendpoints, useproxy_pass http://127.0.0.1:3000/;(note the trailing slash) so/api/itemsmaps cleanly to/items. - Verify the setup by browsing to your domain, saving the generated API token in the form, and submitting a test quote. Then spot-check
/feedand runBASE_URL=https://your-domain bun run smokeif you want automated confirmation. Assets should appear under/srv/quote-cards/data.
For a more detailed walkthrough (including smoke tests, backups, and TLS tips) see deploy/README.md.
- DNS: Point an
Arecord (and optionallywwwCNAME) at your VPS IP. Wait for propagation (dig quoote.wtf +shortshould return the droplet address). - App config: Set
SITE_ORIGIN=https://your-domainin/etc/quote-cards.env, then restart the services:When you changesudo systemctl restart quote-cards-api.service quote-cards-worker.service
SITE_ORIGIN, requeue existing quotes so their embeds/OG images point at the new host. You can loop over API items and PATCH them back toqueued, or run a SQLite update:The worker will regenerate assets on the next pass.sudo -u quote-cards -H sqlite3 /srv/quote-cards/data/db.sqlite \ "update items set render_status = 'queued' where render_status = 'rendered';" - Nginx hostnames: Update
/etc/nginx/sites-available/quote-cards.confsoserver_namematches the domain(s)—typos here make Certbot fail. Ensure only one server block listens on80for that host and reload Nginx (sudo nginx -t && sudo systemctl reload nginx). - TLS certificates: Install Certbot and issue a Let’s Encrypt cert:
Certbot adds the
sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d quoote.wtf -d www.quoote.wtf
listen 443 sslblock automatically. If multiple domains share the site, include them all in the-dlist. - Redirect to HTTPS: Accept Certbot’s redirect prompt (or add a tiny
server { listen 80; server_name www.example.com; return 301 https://example.com$request_uri; }block). Confirm with:
curl -I https://quoote.wtf
curl -I https://www.quoote.wtf
curl -I https://quoote.wtf/feed- Renewal: Certbot installs a cron/systemd timer. Test it anytime with
sudo certbot renew --dry-run.
.
├─ public/
│ ├─ index.html # quote submission form (loads shared CSS/JS)
│ ├─ about.html # static about page
│ └─ assets/
│ ├─ app.js # front-end logic (token storage + form handler)
│ └─ base.css # shared styling for every page
├─ src/
│ ├─ api.ts # Bun HTTP server
│ ├─ worker.ts # render queue processor
│ ├─ web/ # HTML helpers (e.g., feed page renderer)
│ ├─ lib/ # config, db, auth, RSS, filesystem helpers
│ ├─ types/ # item type registry + quote schema/normalizers
│ ├─ render/ # renderer registry + Satori/Resvg quote renderer
│ └─ scripts/ # CLI utilities (migrate, smoke tests, key generation)
├─ migrations/ # SQLite migration files
├─ data/ # generated assets + SQLite (gitignored)
└─ bunfig.toml / tsconfig.json
The quote renderer uses Satori to build SVGs, Resvg to rasterize, and jpeg-js to encode OG cards. Templates live in src/render and share escaping helpers from src/lib/html.ts.
- Support multiple users by introducing a
userstable, associating quotes with owners, and scoping API responses accordingly. - Offer Farcaster authentication (miniapp) so each FID posts to its own quote workspace without sharing raw API keys.
- Expand renderers (additional item types, alternative themes) once the authentication model is in place.
buncommand not found (sudo/systemd): Prependexport PATH="$HOME/.bun/bin:$PATH"when running commands asquote-cards, or edit the systemd units to pointExecStartat/srv/quote-cards/.bun/bin/bun. Without that, both the API and worker will exit with status127.- API keys never validate: Make sure
bun run key:createruns with the production environment loaded so it writes to/srv/quote-cards/data/db.sqlite. Source/etc/quote-cards.env(or export the variables manually), then restartquote-cards-api.serviceto clear the 10 s key cache if the service is already running. You can confirm the key is stored withsudo -u quote-cards -H sqlite3 /srv/quote-cards/data/db.sqlite 'select id, name, last_used_at from api_keys;'. - 401 even though the token exists: Double-check the header format. It must be
Authorization: Bearer <token>—leaving outBeareror surrounding the token with quotes/brackets will fail. - Reverse proxy blocks the UI form: The sample Nginx config returns
404at/. Replace that block with aproxy_pass http://127.0.0.1:3000/stanza if you want the built-in submission form on your domain root. - Certbot can’t install the cert: Ensure the HTTP block’s
server_namematches every hostname you pass with-d. If the challenge gets HTML instead of the token, another virtual host (or a DNS parking page) is serving the request. Fix the host mapping, reload Nginx, and re-runcertbot install --cert-name <domain>.