Vibecode QR Tiger
track this build7 phases, 15 steps, beginner friendly0%A library call with a UI. Peak "why is this a subscription."
You are building a lean indie version of QR Tiger.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# QR Tiger · indie build
A QR code generator you host: live preview, colours, dot styles and a centre logo, PNG and SVG export, plus dynamic codes on your own domain that redirect through /r/:slug so a printed code can be repointed later, with per-code scan counts. The domain is the one thing you must keep forever.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Rendering | qr-code-styling 1.5.x, vendored | handles dots, corners and logos; pinned and served by you, not a CDN |
| Runtime | Node 22, node:http and node:sqlite | a redirect is one lookup and a 302 |
| Database | SQLite | codes and scans in one file |
| Hosting | A VPS behind Caddy on a domain you will keep | a printed code outlives the tool that made it |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **A phone with a camera** · free
- Why: Every check in Phases 1 to 3 is scanning the code with a real camera. Emulators do not count.
- Get it: Any modern phone; the stock camera app scans QR codes.
- [ ] **A square logo file (optional)** (optional) · free
- Why: Phase 3 tests the centre-logo feature; a real logo shows whether it still scans.
- Get it: Your logo as PNG or SVG, at least 256x256, ideally with transparent background.
- [ ] **A domain you will keep for years** · roughly $10 a year
- Why: Dynamic codes redirect through your domain. If the domain lapses, every printed code becomes dead paper. Do not use a domain you might drop.
- Get it: Register a short one at Porkbun or Cloudflare Registrar and turn on auto-renew. A subdomain of a domain you already keep is fine.
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir qr && cd qr && git init && npm init -y && npm pkg set type=module
npm install qr-code-styling@1.5.0
mkdir -p public/vendor data && cp node_modules/qr-code-styling/lib/qr-code-styling.js public/vendor/
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Hosted redirects on someone else's short domain. Yours must be a domain you will keep.
- Their scan-analytics product and bulk generation UI.
- hosted dynamic-QR redirects on their domain
- their scan-analytics dashboard
- bulk generation UI
If one of those is essential to you, that is the reason to keep paying for QR Tiger, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · QR Tiger
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a QR code generator like QR Tiger, self-hosted. Build it in phases, in
the order below. Do not write the whole tool in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- `qr-code-styling` v1.5.x for rendering, pinned to an exact version and vendored
into the repo rather than loaded from a CDN at runtime.
- Node 22 with `node:http` and `node:sqlite` for the dynamic-code service. No
Express, no framework.
- The generator page is plain HTML and vanilla JS. No React, no build step.
### Data model (create this before Phase 1)
- `codes`: id, slug (short, unambiguous alphabet, CSPRNG-generated), target_url,
label, created_at, updated_at, active (bool)
- `scans`: id, code_id, scanned_at, referer, user_agent_class, ip_hash
Store a coarse user-agent bucket and a salted IP hash, never the raw values.
A `slug` is permanent: rewriting a slug invalidates every printed code that uses
it, which is the one unrecoverable mistake this tool can make.
### Phase 1 · Static generator
Build: a single page · a text field for a URL or arbitrary text, and a live QR
preview that re-renders as you type, debounced. Instantiate `QRCodeStyling` with
`{ width, height, type: 'canvas', data }` and mount it with `.append(container)`.
Done when: typing a URL produces a code that a phone camera resolves to that
exact URL, and clearing the field leaves no broken canvas behind.
Do not build yet: styling controls, logo, export, the server.
### Phase 2 · Customization
Build: the controls · foreground and background color, dot style
(`dotsOptions.type`: square, rounded, classy), corner style
(`cornersSquareOptions`), and margin. Every control updates the preview live with
no Apply button. Warn in the UI when the foreground/background contrast drops
below a scannable ratio, because a beautiful unscannable code is the failure mode
users ship without noticing.
Done when: every control visibly changes the preview, and a low-contrast
combination shows a warning while still rendering.
### Phase 3 · Center logo
Build: an optional logo upload wired to `image` and `imageOptions`
(`imageSize`, `margin`, `hideBackgroundDots`). Raise the error-correction level
to H whenever a logo is present, and cap the logo at roughly 25% of the code.
Done when: a code with a logo at maximum size still scans on a phone from 30cm,
and removing the logo restores the previous error-correction level.
### Phase 4 · Export
Build: PNG and SVG export via `download({ name, extension })`, plus a size
selector (512, 1024, 2048) and a "copy PNG to clipboard" button. For clipboard,
always pass a `Promise<Blob>` into `ClipboardItem` rather than a resolved Blob ·
Safari requires the promise form, Chromium accepts it, so one code path works
everywhere. Call it directly inside the click handler or the gesture is lost.
Done when: PNG and SVG both download and reopen correctly, the 2048px export is
sharp, and copy-to-clipboard pastes an image in both Safari and a Chromium
browser.
### Phase 5 · Dynamic codes
Build: `GET /r/:slug` issuing a `302` to `target_url`, plus `/admin` behind basic
auth from `.env` with CRUD over codes. Editing a target must never change the
slug. Deactivating a code redirects to a configurable fallback page rather than
404ing · a dead printed code should explain itself, not error.
Done when: generating a code for `/r/:slug`, printing it, scanning it, then
editing the target and scanning again reaches the new destination with no
regeneration, and a deactivated code lands on the fallback page.
### Phase 6 · Scan analytics
Build: per-code scan logging on the redirect path (write after issuing the
redirect, never before), with totals and a 30-day count in the admin list and a
per-code sparkline as inline SVG. Filter bot user agents into a separate bucket
· link scanners and chat-app previewers will inflate counts otherwise.
Done when: a scan increments the count, a Slack link-preview fetch is bucketed as
a bot and excluded from the headline number, and the redirect still works with
the database stopped.
### Phase 7 · Deploy
Build: a `/healthz` endpoint, a nightly SQLite backup, a systemd unit, and the
README.
Done when: a reader goes from clone to a working dynamic code on their own domain
using only the README.
### Out of scope (and why)
- Hosted redirects on someone else's short domain. Yours must be a domain you
will keep · a printed code outlives the tool that made it.
- Their scan-analytics product and bulk generation UI.
### README must contain
- The warning, stated once and plainly: if this server or domain goes away, every
printed dynamic code becomes dead paper. Static codes have no such dependency ·
use dynamic only when you genuinely need to edit the target later.
- Which error-correction level is used and why it changes with a logo.
===== AGENTS.md =====
# Agent instructions · QR Tiger indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: qr-code-styling 1.5.x, vendored, Node 22, node:http and node:sqlite, SQLite, A VPS behind Caddy on a domain you will keep. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- A slug is permanent. Rewriting one invalidates every printed code that uses it, which is the one unrecoverable mistake this tool can make.
===== BUILD_PLAN.md =====
# Build plan · QR Tiger
A QR code generator you host: live preview, colours, dot styles and a centre logo, PNG and SVG export, plus dynamic codes on your own domain that redirect through /r/:slug so a printed code can be repointed later, with per-code scan counts. The domain is the one thing you must keep forever.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Static generator
Type text, see a code, scan it.
### Steps
1. Create the project and vendor qr-code-styling
Install the pinned version and copy its browser bundle into public/vendor so nothing loads from a CDN at runtime.
Files: `public/index.html`, `public/app.js`
```sh
mkdir qr && cd qr && git init && npm init -y && npm pkg set type=module
npm install qr-code-styling@1.5.0
mkdir -p public/vendor data && cp node_modules/qr-code-styling/lib/qr-code-styling.js public/vendor/
```
2. Build the page: a text field and a live preview
new QRCodeStyling({ width: 300, height: 300, type: 'canvas', data }) then .append(container). Re-render on input with a 150 ms debounce.
### Done when
- [ ] Typing a URL produces a code a phone camera resolves to exactly that URL
- [ ] Clearing the field leaves no broken canvas behind
## Phase 2 · Customization
Colours and styles that update live, with a warning when the result would not scan.
### Steps
1. Add colour, dot style, corner style and margin controls
dotsOptions.color and type (square, rounded, classy), cornersSquareOptions, backgroundOptions.color, margin. Every control re-renders immediately; no Apply button.
2. Warn on low contrast
Compute the contrast ratio between foreground and background; below roughly 3:1 show a warning while still rendering. A beautiful unscannable code is the failure users ship.
### Done when
- [ ] Every control visibly changes the preview
- [ ] A low-contrast pair shows the warning and the code still renders
- [ ] A rounded-dots code still scans
## Phase 3 · Centre logo
A logo in the middle that does not break scanning.
### Steps
1. Wire an image upload to image and imageOptions
imageSize around 0.3, margin, hideBackgroundDots true. Read the file locally with FileReader; nothing is uploaded.
2. Raise error correction to H whenever a logo is present
qrOptions.errorCorrectionLevel = 'H' with a logo, back to M without. Cap the logo at roughly a quarter of the code.
### Done when
- [ ] A code with a logo at maximum size scans from 30 cm
- [ ] Removing the logo restores the previous error-correction level
## Phase 4 · Export
PNG and SVG that reopen correctly, and copy to clipboard that works in Safari too.
### Steps
1. Add download buttons with a size selector
download({ name, extension: 'png' | 'svg' }) at 512, 1024 or 2048 by re-instantiating at that size.
2. Add copy to clipboard
Always pass a Promise resolving to a Blob into ClipboardItem: Safari requires the promise form and Chromium accepts it, so one code path works everywhere. Call navigator.clipboard.write inside the click handler or the user gesture is lost.
### Done when
- [ ] PNG and SVG both download and reopen correctly
- [ ] The 2048px export is sharp
- [ ] Copy pastes an image in both Safari and a Chromium browser
## Phase 5 · Dynamic codes
Codes that point at /r/:slug so the target can change after printing.
### Steps
1. Create the codes table and the server
codes (id, slug unique, target_url, label, created_at, updated_at, active). Slugs 6+ characters from an alphabet without 0/O/1/l/I, CSPRNG-generated, or custom.
Files: `server.mjs`
2. Implement GET /r/:slug
302 to target_url. Unknown or inactive slugs redirect to FALLBACK_URL rather than 404ing.
3. Build /admin behind basic auth with CRUD
Editing a target must never change the slug. Deactivating keeps the row. Each row shows its code image and a download button.
### Done when
- [ ] A printed code for /r/:slug scans, then after editing the target scans to the new destination with no regeneration
- [ ] A deactivated code lands on the fallback page
- [ ] Creating a slug named admin or r is refused
### Watch out
- A slug is permanent. Rewriting one invalidates every printed code that uses it, which is the one unrecoverable mistake this tool can make.
## Phase 6 · Scan analytics
Counts you can explain, written after the redirect.
### Steps
1. Log scans after issuing the redirect
scans (id, code_id, scanned_at, referer, user_agent_class, ip_hash). Never before the 302.
2. Bucket bots and show totals plus a 30-day sparkline per code
Link scanners and chat previewers inflate counts otherwise.
### Done when
- [ ] A scan increments the count
- [ ] A Slack link preview is bucketed as bot and excluded
- [ ] The redirect still works with the database stopped
## Phase 7 · Deploy
Live on the domain you will keep, backed up, documented.
### Steps
1. Add /healthz, systemd, Caddy and a nightly backup
Files: `deploy/qr.service`, `Caddyfile`
2. Turn on domain auto-renew and write the README
README: the domain-is-forever warning stated once plainly, which error-correction level is used and why it changes with a logo, and when to use static instead of dynamic.
Files: `README.md`
### Done when
- [ ] A reader goes from clone to a working dynamic code on their own domain using only the README
- [ ] Auto-renew is confirmed on at the registrar
## Not in this build
- Hosted redirects on someone else's short domain. Yours must be a domain you will keep.
- Their scan-analytics product and bulk generation UI.
## After v1, if you want it
- Bulk creation from a CSV with a ZIP of PNGs
- Per-code UTM parameters appended on redirect
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file for codes and scans.
DATABASE_PATH=./data/qr.db
# Required. Public base of the redirect domain. Baked into every dynamic code.
SITE_URL=https://go.yourdomain.com
# Required. Where a deactivated or unknown code lands. A dead printed code should explain itself.
FALLBACK_URL=https://yourdomain.com/this-code-is-inactive
# Required · secret. openssl rand -hex 32, once. For hashing scanner IPs.
IP_SALT=hex-from-openssl-rand
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
You are building a lean indie version of QR Tiger.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# QR Tiger · indie build
A QR code generator you host: live preview, colours, dot styles and a centre logo, PNG and SVG export, plus dynamic codes on your own domain that redirect through /r/:slug so a printed code can be repointed later, with per-code scan counts. The domain is the one thing you must keep forever.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Rendering | qr-code-styling 1.5.x, vendored | handles dots, corners and logos; pinned and served by you, not a CDN |
| Runtime | Node 22, node:http and node:sqlite | a redirect is one lookup and a 302 |
| Database | SQLite | codes and scans in one file |
| Hosting | A VPS behind Caddy on a domain you will keep | a printed code outlives the tool that made it |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **A phone with a camera** · free
- Why: Every check in Phases 1 to 3 is scanning the code with a real camera. Emulators do not count.
- Get it: Any modern phone; the stock camera app scans QR codes.
- [ ] **A square logo file (optional)** (optional) · free
- Why: Phase 3 tests the centre-logo feature; a real logo shows whether it still scans.
- Get it: Your logo as PNG or SVG, at least 256x256, ideally with transparent background.
- [ ] **A domain you will keep for years** · roughly $10 a year
- Why: Dynamic codes redirect through your domain. If the domain lapses, every printed code becomes dead paper. Do not use a domain you might drop.
- Get it: Register a short one at Porkbun or Cloudflare Registrar and turn on auto-renew. A subdomain of a domain you already keep is fine.
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir qr && cd qr && git init && npm init -y && npm pkg set type=module
npm install qr-code-styling@1.5.0
mkdir -p public/vendor data && cp node_modules/qr-code-styling/lib/qr-code-styling.js public/vendor/
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Hosted redirects on someone else's short domain. Yours must be a domain you will keep.
- Their scan-analytics product and bulk generation UI.
- hosted dynamic-QR redirects on their domain
- their scan-analytics dashboard
- bulk generation UI
If one of those is essential to you, that is the reason to keep paying for QR Tiger, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · QR Tiger
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a QR code generator like QR Tiger, self-hosted. Build it in phases, in
the order below. Do not write the whole tool in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- `qr-code-styling` v1.5.x for rendering, pinned to an exact version and vendored
into the repo rather than loaded from a CDN at runtime.
- Node 22 with `node:http` and `node:sqlite` for the dynamic-code service. No
Express, no framework.
- The generator page is plain HTML and vanilla JS. No React, no build step.
### Data model (create this before Phase 1)
- `codes`: id, slug (short, unambiguous alphabet, CSPRNG-generated), target_url,
label, created_at, updated_at, active (bool)
- `scans`: id, code_id, scanned_at, referer, user_agent_class, ip_hash
Store a coarse user-agent bucket and a salted IP hash, never the raw values.
A `slug` is permanent: rewriting a slug invalidates every printed code that uses
it, which is the one unrecoverable mistake this tool can make.
### Phase 1 · Static generator
Build: a single page · a text field for a URL or arbitrary text, and a live QR
preview that re-renders as you type, debounced. Instantiate `QRCodeStyling` with
`{ width, height, type: 'canvas', data }` and mount it with `.append(container)`.
Done when: typing a URL produces a code that a phone camera resolves to that
exact URL, and clearing the field leaves no broken canvas behind.
Do not build yet: styling controls, logo, export, the server.
### Phase 2 · Customization
Build: the controls · foreground and background color, dot style
(`dotsOptions.type`: square, rounded, classy), corner style
(`cornersSquareOptions`), and margin. Every control updates the preview live with
no Apply button. Warn in the UI when the foreground/background contrast drops
below a scannable ratio, because a beautiful unscannable code is the failure mode
users ship without noticing.
Done when: every control visibly changes the preview, and a low-contrast
combination shows a warning while still rendering.
### Phase 3 · Center logo
Build: an optional logo upload wired to `image` and `imageOptions`
(`imageSize`, `margin`, `hideBackgroundDots`). Raise the error-correction level
to H whenever a logo is present, and cap the logo at roughly 25% of the code.
Done when: a code with a logo at maximum size still scans on a phone from 30cm,
and removing the logo restores the previous error-correction level.
### Phase 4 · Export
Build: PNG and SVG export via `download({ name, extension })`, plus a size
selector (512, 1024, 2048) and a "copy PNG to clipboard" button. For clipboard,
always pass a `Promise<Blob>` into `ClipboardItem` rather than a resolved Blob ·
Safari requires the promise form, Chromium accepts it, so one code path works
everywhere. Call it directly inside the click handler or the gesture is lost.
Done when: PNG and SVG both download and reopen correctly, the 2048px export is
sharp, and copy-to-clipboard pastes an image in both Safari and a Chromium
browser.
### Phase 5 · Dynamic codes
Build: `GET /r/:slug` issuing a `302` to `target_url`, plus `/admin` behind basic
auth from `.env` with CRUD over codes. Editing a target must never change the
slug. Deactivating a code redirects to a configurable fallback page rather than
404ing · a dead printed code should explain itself, not error.
Done when: generating a code for `/r/:slug`, printing it, scanning it, then
editing the target and scanning again reaches the new destination with no
regeneration, and a deactivated code lands on the fallback page.
### Phase 6 · Scan analytics
Build: per-code scan logging on the redirect path (write after issuing the
redirect, never before), with totals and a 30-day count in the admin list and a
per-code sparkline as inline SVG. Filter bot user agents into a separate bucket
· link scanners and chat-app previewers will inflate counts otherwise.
Done when: a scan increments the count, a Slack link-preview fetch is bucketed as
a bot and excluded from the headline number, and the redirect still works with
the database stopped.
### Phase 7 · Deploy
Build: a `/healthz` endpoint, a nightly SQLite backup, a systemd unit, and the
README.
Done when: a reader goes from clone to a working dynamic code on their own domain
using only the README.
### Out of scope (and why)
- Hosted redirects on someone else's short domain. Yours must be a domain you
will keep · a printed code outlives the tool that made it.
- Their scan-analytics product and bulk generation UI.
### README must contain
- The warning, stated once and plainly: if this server or domain goes away, every
printed dynamic code becomes dead paper. Static codes have no such dependency ·
use dynamic only when you genuinely need to edit the target later.
- Which error-correction level is used and why it changes with a logo.
===== AGENTS.md =====
# Agent instructions · QR Tiger indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: qr-code-styling 1.5.x, vendored, Node 22, node:http and node:sqlite, SQLite, A VPS behind Caddy on a domain you will keep. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- A slug is permanent. Rewriting one invalidates every printed code that uses it, which is the one unrecoverable mistake this tool can make.
===== BUILD_PLAN.md =====
# Build plan · QR Tiger
A QR code generator you host: live preview, colours, dot styles and a centre logo, PNG and SVG export, plus dynamic codes on your own domain that redirect through /r/:slug so a printed code can be repointed later, with per-code scan counts. The domain is the one thing you must keep forever.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Static generator
Type text, see a code, scan it.
### Steps
1. Create the project and vendor qr-code-styling
Install the pinned version and copy its browser bundle into public/vendor so nothing loads from a CDN at runtime.
Files: `public/index.html`, `public/app.js`
```sh
mkdir qr && cd qr && git init && npm init -y && npm pkg set type=module
npm install qr-code-styling@1.5.0
mkdir -p public/vendor data && cp node_modules/qr-code-styling/lib/qr-code-styling.js public/vendor/
```
2. Build the page: a text field and a live preview
new QRCodeStyling({ width: 300, height: 300, type: 'canvas', data }) then .append(container). Re-render on input with a 150 ms debounce.
### Done when
- [ ] Typing a URL produces a code a phone camera resolves to exactly that URL
- [ ] Clearing the field leaves no broken canvas behind
## Phase 2 · Customization
Colours and styles that update live, with a warning when the result would not scan.
### Steps
1. Add colour, dot style, corner style and margin controls
dotsOptions.color and type (square, rounded, classy), cornersSquareOptions, backgroundOptions.color, margin. Every control re-renders immediately; no Apply button.
2. Warn on low contrast
Compute the contrast ratio between foreground and background; below roughly 3:1 show a warning while still rendering. A beautiful unscannable code is the failure users ship.
### Done when
- [ ] Every control visibly changes the preview
- [ ] A low-contrast pair shows the warning and the code still renders
- [ ] A rounded-dots code still scans
## Phase 3 · Centre logo
A logo in the middle that does not break scanning.
### Steps
1. Wire an image upload to image and imageOptions
imageSize around 0.3, margin, hideBackgroundDots true. Read the file locally with FileReader; nothing is uploaded.
2. Raise error correction to H whenever a logo is present
qrOptions.errorCorrectionLevel = 'H' with a logo, back to M without. Cap the logo at roughly a quarter of the code.
### Done when
- [ ] A code with a logo at maximum size scans from 30 cm
- [ ] Removing the logo restores the previous error-correction level
## Phase 4 · Export
PNG and SVG that reopen correctly, and copy to clipboard that works in Safari too.
### Steps
1. Add download buttons with a size selector
download({ name, extension: 'png' | 'svg' }) at 512, 1024 or 2048 by re-instantiating at that size.
2. Add copy to clipboard
Always pass a Promise resolving to a Blob into ClipboardItem: Safari requires the promise form and Chromium accepts it, so one code path works everywhere. Call navigator.clipboard.write inside the click handler or the user gesture is lost.
### Done when
- [ ] PNG and SVG both download and reopen correctly
- [ ] The 2048px export is sharp
- [ ] Copy pastes an image in both Safari and a Chromium browser
## Phase 5 · Dynamic codes
Codes that point at /r/:slug so the target can change after printing.
### Steps
1. Create the codes table and the server
codes (id, slug unique, target_url, label, created_at, updated_at, active). Slugs 6+ characters from an alphabet without 0/O/1/l/I, CSPRNG-generated, or custom.
Files: `server.mjs`
2. Implement GET /r/:slug
302 to target_url. Unknown or inactive slugs redirect to FALLBACK_URL rather than 404ing.
3. Build /admin behind basic auth with CRUD
Editing a target must never change the slug. Deactivating keeps the row. Each row shows its code image and a download button.
### Done when
- [ ] A printed code for /r/:slug scans, then after editing the target scans to the new destination with no regeneration
- [ ] A deactivated code lands on the fallback page
- [ ] Creating a slug named admin or r is refused
### Watch out
- A slug is permanent. Rewriting one invalidates every printed code that uses it, which is the one unrecoverable mistake this tool can make.
## Phase 6 · Scan analytics
Counts you can explain, written after the redirect.
### Steps
1. Log scans after issuing the redirect
scans (id, code_id, scanned_at, referer, user_agent_class, ip_hash). Never before the 302.
2. Bucket bots and show totals plus a 30-day sparkline per code
Link scanners and chat previewers inflate counts otherwise.
### Done when
- [ ] A scan increments the count
- [ ] A Slack link preview is bucketed as bot and excluded
- [ ] The redirect still works with the database stopped
## Phase 7 · Deploy
Live on the domain you will keep, backed up, documented.
### Steps
1. Add /healthz, systemd, Caddy and a nightly backup
Files: `deploy/qr.service`, `Caddyfile`
2. Turn on domain auto-renew and write the README
README: the domain-is-forever warning stated once plainly, which error-correction level is used and why it changes with a logo, and when to use static instead of dynamic.
Files: `README.md`
### Done when
- [ ] A reader goes from clone to a working dynamic code on their own domain using only the README
- [ ] Auto-renew is confirmed on at the registrar
## Not in this build
- Hosted redirects on someone else's short domain. Yours must be a domain you will keep.
- Their scan-analytics product and bulk generation UI.
## After v1, if you want it
- Bulk creation from a CSV with a ZIP of PNGs
- Per-code UTM parameters appended on redirect
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file for codes and scans.
DATABASE_PATH=./data/qr.db
# Required. Public base of the redirect domain. Baked into every dynamic code.
SITE_URL=https://go.yourdomain.com
# Required. Where a deactivated or unknown code lands. A dead printed code should explain itself.
FALLBACK_URL=https://yourdomain.com/this-code-is-inactive
# Required · secret. openssl rand -hex 32, once. For hashing scanner IPs.
IP_SALT=hex-from-openssl-rand
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
You are building a production product version of QR Tiger.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== PRODUCT.md =====
# QR Tiger · product brief
## Problem
A library call with a UI. Peak "why is this a subscription."
## Product outcome
A QR service you could run for a small business: static codes for free, dynamic codes on a domain with auto-renew and backups, and scan counts that separate people from bots.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- Implement the core workflow described in ARCHITECTURE.md
## Explicit non-goals for v1
- Hosted redirects on someone else's short domain. Yours must be a domain you will keep.
- Their scan-analytics product and bulk generation UI.
- hosted dynamic-QR redirects on their domain
- their scan-analytics dashboard
- bulk generation UI
## Success criteria
- Domain auto-renew on, with a calendar reminder a month before expiry
- One restore drill performed and dated
- External check on a live /r/ slug passing
- README warns about domain permanence
===== BRIEF.md =====
# Build brief · QR Tiger
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a QR code generator like QR Tiger, self-hosted. Build it in phases, in
the order below. Do not write the whole tool in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- `qr-code-styling` v1.5.x for rendering, pinned to an exact version and vendored
into the repo rather than loaded from a CDN at runtime.
- Node 22 with `node:http` and `node:sqlite` for the dynamic-code service. No
Express, no framework.
- The generator page is plain HTML and vanilla JS. No React, no build step.
### Data model (create this before Phase 1)
- `codes`: id, slug (short, unambiguous alphabet, CSPRNG-generated), target_url,
label, created_at, updated_at, active (bool)
- `scans`: id, code_id, scanned_at, referer, user_agent_class, ip_hash
Store a coarse user-agent bucket and a salted IP hash, never the raw values.
A `slug` is permanent: rewriting a slug invalidates every printed code that uses
it, which is the one unrecoverable mistake this tool can make.
### Phase 1 · Static generator
Build: a single page · a text field for a URL or arbitrary text, and a live QR
preview that re-renders as you type, debounced. Instantiate `QRCodeStyling` with
`{ width, height, type: 'canvas', data }` and mount it with `.append(container)`.
Done when: typing a URL produces a code that a phone camera resolves to that
exact URL, and clearing the field leaves no broken canvas behind.
Do not build yet: styling controls, logo, export, the server.
### Phase 2 · Customization
Build: the controls · foreground and background color, dot style
(`dotsOptions.type`: square, rounded, classy), corner style
(`cornersSquareOptions`), and margin. Every control updates the preview live with
no Apply button. Warn in the UI when the foreground/background contrast drops
below a scannable ratio, because a beautiful unscannable code is the failure mode
users ship without noticing.
Done when: every control visibly changes the preview, and a low-contrast
combination shows a warning while still rendering.
### Phase 3 · Center logo
Build: an optional logo upload wired to `image` and `imageOptions`
(`imageSize`, `margin`, `hideBackgroundDots`). Raise the error-correction level
to H whenever a logo is present, and cap the logo at roughly 25% of the code.
Done when: a code with a logo at maximum size still scans on a phone from 30cm,
and removing the logo restores the previous error-correction level.
### Phase 4 · Export
Build: PNG and SVG export via `download({ name, extension })`, plus a size
selector (512, 1024, 2048) and a "copy PNG to clipboard" button. For clipboard,
always pass a `Promise<Blob>` into `ClipboardItem` rather than a resolved Blob ·
Safari requires the promise form, Chromium accepts it, so one code path works
everywhere. Call it directly inside the click handler or the gesture is lost.
Done when: PNG and SVG both download and reopen correctly, the 2048px export is
sharp, and copy-to-clipboard pastes an image in both Safari and a Chromium
browser.
### Phase 5 · Dynamic codes
Build: `GET /r/:slug` issuing a `302` to `target_url`, plus `/admin` behind basic
auth from `.env` with CRUD over codes. Editing a target must never change the
slug. Deactivating a code redirects to a configurable fallback page rather than
404ing · a dead printed code should explain itself, not error.
Done when: generating a code for `/r/:slug`, printing it, scanning it, then
editing the target and scanning again reaches the new destination with no
regeneration, and a deactivated code lands on the fallback page.
### Phase 6 · Scan analytics
Build: per-code scan logging on the redirect path (write after issuing the
redirect, never before), with totals and a 30-day count in the admin list and a
per-code sparkline as inline SVG. Filter bot user agents into a separate bucket
· link scanners and chat-app previewers will inflate counts otherwise.
Done when: a scan increments the count, a Slack link-preview fetch is bucketed as
a bot and excluded from the headline number, and the redirect still works with
the database stopped.
### Phase 7 · Deploy
Build: a `/healthz` endpoint, a nightly SQLite backup, a systemd unit, and the
README.
Done when: a reader goes from clone to a working dynamic code on their own domain
using only the README.
### Out of scope (and why)
- Hosted redirects on someone else's short domain. Yours must be a domain you
will keep · a printed code outlives the tool that made it.
- Their scan-analytics product and bulk generation UI.
### README must contain
- The warning, stated once and plainly: if this server or domain goes away, every
printed dynamic code becomes dead paper. Static codes have no such dependency ·
use dynamic only when you genuinely need to edit the target later.
- Which error-correction level is used and why it changes with a logo.
===== ARCHITECTURE.md =====
# Architecture · QR Tiger
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Rendering | qr-code-styling 1.5.x, vendored | handles dots, corners and logos; pinned and served by you, not a CDN |
| Runtime | Node 22, node:http and node:sqlite | a redirect is one lookup and a 302 |
| Database | SQLite | codes and scans in one file |
| Hosting | A VPS behind Caddy on a domain you will keep | a printed code outlives the tool that made it |
## Modules
Each module has one owner concern and a documented way to replace it.
| Module | Owns | How to replace it |
| --- | --- | --- |
| Generator | the browser page and vendored library | Any renderer producing the same PNG/SVG; the server does not know about it |
| Redirector | /r/:slug and the codes table | A serverless function reading the same table |
| Analytics | scans and bucketing | Drop or replace without touching redirects |
| Admin | CRUD and code images | Any UI; slugs are the contract |
## Configuration
Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists.
- `PORT` · required · Any free port; Caddy proxies to it.
- `DATABASE_PATH` · required · SQLite file for codes and scans.
- `SITE_URL` · required · Public base of the redirect domain. Baked into every dynamic code.
- `FALLBACK_URL` · required · Where a deactivated or unknown code lands. A dead printed code should explain itself.
- `IP_SALT` · required, secret · openssl rand -hex 32, once. For hashing scanner IPs.
- `ADMIN_USER` · required · Any username for the basic-auth admin pages.
- `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password.
## Production baseline
- Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives.
- Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised.
- Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts.
- Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists.
- Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path.
## Decision records
For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
===== AGENTS.md =====
# Agent instructions · QR Tiger product build
- Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: qr-code-styling 1.5.x, vendored, Node 22, node:http and node:sqlite, SQLite, A VPS behind Caddy on a domain you will keep.
- Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit.
- Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present.
- Never invent cryptography or silently weaken a requirement to make a check pass.
- Put every external service behind an interface with a deterministic fake for tests.
- Add migrations and rollback or recovery notes for every persistent data change.
- Log useful operational context without credentials, tokens, passwords or personal data.
- Update documentation and run every check before completing a milestone.
## Known traps
- A slug is permanent. Rewriting one invalidates every printed code that uses it, which is the one unrecoverable mistake this tool can make.
===== MILESTONES.md =====
# Delivery milestones · QR Tiger
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · Static generator
Type text, see a code, scan it.
### Steps
1. Create the project and vendor qr-code-styling
Install the pinned version and copy its browser bundle into public/vendor so nothing loads from a CDN at runtime.
Files: `public/index.html`, `public/app.js`
```sh
mkdir qr && cd qr && git init && npm init -y && npm pkg set type=module
npm install qr-code-styling@1.5.0
mkdir -p public/vendor data && cp node_modules/qr-code-styling/lib/qr-code-styling.js public/vendor/
```
2. Build the page: a text field and a live preview
new QRCodeStyling({ width: 300, height: 300, type: 'canvas', data }) then .append(container). Re-render on input with a 150 ms debounce.
### Done when
- [ ] Typing a URL produces a code a phone camera resolves to exactly that URL
- [ ] Clearing the field leaves no broken canvas behind
## M2 · Customization
Colours and styles that update live, with a warning when the result would not scan.
### Steps
1. Add colour, dot style, corner style and margin controls
dotsOptions.color and type (square, rounded, classy), cornersSquareOptions, backgroundOptions.color, margin. Every control re-renders immediately; no Apply button.
2. Warn on low contrast
Compute the contrast ratio between foreground and background; below roughly 3:1 show a warning while still rendering. A beautiful unscannable code is the failure users ship.
### Done when
- [ ] Every control visibly changes the preview
- [ ] A low-contrast pair shows the warning and the code still renders
- [ ] A rounded-dots code still scans
## M3 · Centre logo
A logo in the middle that does not break scanning.
### Steps
1. Wire an image upload to image and imageOptions
imageSize around 0.3, margin, hideBackgroundDots true. Read the file locally with FileReader; nothing is uploaded.
2. Raise error correction to H whenever a logo is present
qrOptions.errorCorrectionLevel = 'H' with a logo, back to M without. Cap the logo at roughly a quarter of the code.
### Done when
- [ ] A code with a logo at maximum size scans from 30 cm
- [ ] Removing the logo restores the previous error-correction level
## M4 · Export
PNG and SVG that reopen correctly, and copy to clipboard that works in Safari too.
### Steps
1. Add download buttons with a size selector
download({ name, extension: 'png' | 'svg' }) at 512, 1024 or 2048 by re-instantiating at that size.
2. Add copy to clipboard
Always pass a Promise resolving to a Blob into ClipboardItem: Safari requires the promise form and Chromium accepts it, so one code path works everywhere. Call navigator.clipboard.write inside the click handler or the user gesture is lost.
### Done when
- [ ] PNG and SVG both download and reopen correctly
- [ ] The 2048px export is sharp
- [ ] Copy pastes an image in both Safari and a Chromium browser
## M5 · Dynamic codes
Codes that point at /r/:slug so the target can change after printing.
### Steps
1. Create the codes table and the server
codes (id, slug unique, target_url, label, created_at, updated_at, active). Slugs 6+ characters from an alphabet without 0/O/1/l/I, CSPRNG-generated, or custom.
Files: `server.mjs`
2. Implement GET /r/:slug
302 to target_url. Unknown or inactive slugs redirect to FALLBACK_URL rather than 404ing.
3. Build /admin behind basic auth with CRUD
Editing a target must never change the slug. Deactivating keeps the row. Each row shows its code image and a download button.
### Done when
- [ ] A printed code for /r/:slug scans, then after editing the target scans to the new destination with no regeneration
- [ ] A deactivated code lands on the fallback page
- [ ] Creating a slug named admin or r is refused
### Watch out
- A slug is permanent. Rewriting one invalidates every printed code that uses it, which is the one unrecoverable mistake this tool can make.
## M6 · Scan analytics
Counts you can explain, written after the redirect.
### Steps
1. Log scans after issuing the redirect
scans (id, code_id, scanned_at, referer, user_agent_class, ip_hash). Never before the 302.
2. Bucket bots and show totals plus a 30-day sparkline per code
Link scanners and chat previewers inflate counts otherwise.
### Done when
- [ ] A scan increments the count
- [ ] A Slack link preview is bucketed as bot and excluded
- [ ] The redirect still works with the database stopped
## M7 · Deploy
Live on the domain you will keep, backed up, documented.
### Steps
1. Add /healthz, systemd, Caddy and a nightly backup
Files: `deploy/qr.service`, `Caddyfile`
2. Turn on domain auto-renew and write the README
README: the domain-is-forever warning stated once plainly, which error-correction level is used and why it changes with a logo, and when to use static instead of dynamic.
Files: `README.md`
### Done when
- [ ] A reader goes from clone to a working dynamic code on their own domain using only the README
- [ ] Auto-renew is confirmed on at the registrar
## M8 · Operate it like a product (production only)
Only for the product-builder path: know when the redirect is down, never lose the database, and keep the server patched.
### Steps
1. Add a /healthz endpoint and an external uptime check against it
Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices.
2. Write structured request logs and rotate them
One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight.
3. Back the SQLite file up off the machine nightly and test a restore
SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it.
```sh
sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'"
rclone copy /tmp/app-$(date +%F).db remote:backups/
```
4. Lock the box down
Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure.
### Done when
- [ ] Stopping the service triggers an uptime alert within a few minutes
- [ ] A restore from last night's backup contains yesterday's data
- [ ] A port scan from another machine shows only 22, 80 and 443
===== OPERATIONS.md =====
# Operations · QR Tiger
## Backup
SQLite .backup nightly. The codes table is the asset: without it every printed code is dead.
## Restore
Copy back and start; verify one known slug redirects correctly.
Do a restore drill before the first real user, and write the date here when it passes.
## Monitoring
Uptime on /healthz and on one known /r/ slug from an external checker, since a dead redirect is invisible from inside.
## Incident checklist
If the domain expires, renew immediately: registrars hold a grace period. If the database is lost without backup, printed codes cannot be recovered; this is why backups are not optional.
1. Contain the issue without destroying evidence or user data.
2. Record the timeline and affected scope.
3. Rotate exposed secrets and revoke compromised sessions or credentials.
4. Restore from a verified backup when needed.
5. Document the root cause, the remediation and the regression test.
## Release gate
- [ ] Domain auto-renew on, with a calendar reminder a month before expiry
- [ ] One restore drill performed and dated
- [ ] External check on a live /r/ slug passing
- [ ] README warns about domain permanence
## Launch constraint
Do not market omitted QR Tiger capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file for codes and scans.
DATABASE_PATH=./data/qr.db
# Required. Public base of the redirect domain. Baked into every dynamic code.
SITE_URL=https://go.yourdomain.com
# Required. Where a deactivated or unknown code lands. A dead printed code should explain itself.
FALLBACK_URL=https://yourdomain.com/this-code-is-inactive
# Required · secret. openssl rand -hex 32, once. For hashing scanner IPs.
IP_SALT=hex-from-openssl-rand
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
# QR Tiger · indie build A QR code generator you host: live preview, colours, dot styles and a centre logo, PNG and SVG export, plus dynamic codes on your own domain that redirect through /r/:slug so a printed code can be repointed later, with per-code scan counts. The domain is the one thing you must keep forever. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Rendering | qr-code-styling 1.5.x, vendored | handles dots, corners and logos; pinned and served by you, not a CDN | | Runtime | Node 22, node:http and node:sqlite | a redirect is one lookup and a 302 | | Database | SQLite | codes and scans in one file | | Hosting | A VPS behind Caddy on a domain you will keep | a printed code outlives the tool that made it | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **A phone with a camera** · free - Why: Every check in Phases 1 to 3 is scanning the code with a real camera. Emulators do not count. - Get it: Any modern phone; the stock camera app scans QR codes. - [ ] **A square logo file (optional)** (optional) · free - Why: Phase 3 tests the centre-logo feature; a real logo shows whether it still scans. - Get it: Your logo as PNG or SVG, at least 256x256, ideally with transparent background. - [ ] **A domain you will keep for years** · roughly $10 a year - Why: Dynamic codes redirect through your domain. If the domain lapses, every printed code becomes dead paper. Do not use a domain you might drop. - Get it: Register a short one at Porkbun or Cloudflare Registrar and turn on auto-renew. A subdomain of a domain you already keep is fine. - [ ] **A small always-on server (VPS)** (optional) · about $5 a month - Why: This needs one process running all the time with a public address. - Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first. - [ ] **Caddy on the server** (optional) · free - Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work. - Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config. - Verify: caddy version prints a version on the server ## Quick start ```sh mkdir qr && cd qr && git init && npm init -y && npm pkg set type=module npm install qr-code-styling@1.5.0 mkdir -p public/vendor data && cp node_modules/qr-code-styling/lib/qr-code-styling.js public/vendor/ ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Hosted redirects on someone else's short domain. Yours must be a domain you will keep. - Their scan-analytics product and bulk generation UI. - hosted dynamic-QR redirects on their domain - their scan-analytics dashboard - bulk generation UI If one of those is essential to you, that is the reason to keep paying for QR Tiger, and the README should say so rather than pretend.
# Build brief · QR Tiger
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a QR code generator like QR Tiger, self-hosted. Build it in phases, in
the order below. Do not write the whole tool in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- `qr-code-styling` v1.5.x for rendering, pinned to an exact version and vendored
into the repo rather than loaded from a CDN at runtime.
- Node 22 with `node:http` and `node:sqlite` for the dynamic-code service. No
Express, no framework.
- The generator page is plain HTML and vanilla JS. No React, no build step.
### Data model (create this before Phase 1)
- `codes`: id, slug (short, unambiguous alphabet, CSPRNG-generated), target_url,
label, created_at, updated_at, active (bool)
- `scans`: id, code_id, scanned_at, referer, user_agent_class, ip_hash
Store a coarse user-agent bucket and a salted IP hash, never the raw values.
A `slug` is permanent: rewriting a slug invalidates every printed code that uses
it, which is the one unrecoverable mistake this tool can make.
### Phase 1 · Static generator
Build: a single page · a text field for a URL or arbitrary text, and a live QR
preview that re-renders as you type, debounced. Instantiate `QRCodeStyling` with
`{ width, height, type: 'canvas', data }` and mount it with `.append(container)`.
Done when: typing a URL produces a code that a phone camera resolves to that
exact URL, and clearing the field leaves no broken canvas behind.
Do not build yet: styling controls, logo, export, the server.
### Phase 2 · Customization
Build: the controls · foreground and background color, dot style
(`dotsOptions.type`: square, rounded, classy), corner style
(`cornersSquareOptions`), and margin. Every control updates the preview live with
no Apply button. Warn in the UI when the foreground/background contrast drops
below a scannable ratio, because a beautiful unscannable code is the failure mode
users ship without noticing.
Done when: every control visibly changes the preview, and a low-contrast
combination shows a warning while still rendering.
### Phase 3 · Center logo
Build: an optional logo upload wired to `image` and `imageOptions`
(`imageSize`, `margin`, `hideBackgroundDots`). Raise the error-correction level
to H whenever a logo is present, and cap the logo at roughly 25% of the code.
Done when: a code with a logo at maximum size still scans on a phone from 30cm,
and removing the logo restores the previous error-correction level.
### Phase 4 · Export
Build: PNG and SVG export via `download({ name, extension })`, plus a size
selector (512, 1024, 2048) and a "copy PNG to clipboard" button. For clipboard,
always pass a `Promise<Blob>` into `ClipboardItem` rather than a resolved Blob ·
Safari requires the promise form, Chromium accepts it, so one code path works
everywhere. Call it directly inside the click handler or the gesture is lost.
Done when: PNG and SVG both download and reopen correctly, the 2048px export is
sharp, and copy-to-clipboard pastes an image in both Safari and a Chromium
browser.
### Phase 5 · Dynamic codes
Build: `GET /r/:slug` issuing a `302` to `target_url`, plus `/admin` behind basic
auth from `.env` with CRUD over codes. Editing a target must never change the
slug. Deactivating a code redirects to a configurable fallback page rather than
404ing · a dead printed code should explain itself, not error.
Done when: generating a code for `/r/:slug`, printing it, scanning it, then
editing the target and scanning again reaches the new destination with no
regeneration, and a deactivated code lands on the fallback page.
### Phase 6 · Scan analytics
Build: per-code scan logging on the redirect path (write after issuing the
redirect, never before), with totals and a 30-day count in the admin list and a
per-code sparkline as inline SVG. Filter bot user agents into a separate bucket
· link scanners and chat-app previewers will inflate counts otherwise.
Done when: a scan increments the count, a Slack link-preview fetch is bucketed as
a bot and excluded from the headline number, and the redirect still works with
the database stopped.
### Phase 7 · Deploy
Build: a `/healthz` endpoint, a nightly SQLite backup, a systemd unit, and the
README.
Done when: a reader goes from clone to a working dynamic code on their own domain
using only the README.
### Out of scope (and why)
- Hosted redirects on someone else's short domain. Yours must be a domain you
will keep · a printed code outlives the tool that made it.
- Their scan-analytics product and bulk generation UI.
### README must contain
- The warning, stated once and plainly: if this server or domain goes away, every
printed dynamic code becomes dead paper. Static codes have no such dependency ·
use dynamic only when you genuinely need to edit the target later.
- Which error-correction level is used and why it changes with a logo.# Agent instructions · QR Tiger indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: qr-code-styling 1.5.x, vendored, Node 22, node:http and node:sqlite, SQLite, A VPS behind Caddy on a domain you will keep. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A slug is permanent. Rewriting one invalidates every printed code that uses it, which is the one unrecoverable mistake this tool can make.
# Build plan · QR Tiger
A QR code generator you host: live preview, colours, dot styles and a centre logo, PNG and SVG export, plus dynamic codes on your own domain that redirect through /r/:slug so a printed code can be repointed later, with per-code scan counts. The domain is the one thing you must keep forever.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Static generator
Type text, see a code, scan it.
### Steps
1. Create the project and vendor qr-code-styling
Install the pinned version and copy its browser bundle into public/vendor so nothing loads from a CDN at runtime.
Files: `public/index.html`, `public/app.js`
```sh
mkdir qr && cd qr && git init && npm init -y && npm pkg set type=module
npm install qr-code-styling@1.5.0
mkdir -p public/vendor data && cp node_modules/qr-code-styling/lib/qr-code-styling.js public/vendor/
```
2. Build the page: a text field and a live preview
new QRCodeStyling({ width: 300, height: 300, type: 'canvas', data }) then .append(container). Re-render on input with a 150 ms debounce.
### Done when
- [ ] Typing a URL produces a code a phone camera resolves to exactly that URL
- [ ] Clearing the field leaves no broken canvas behind
## Phase 2 · Customization
Colours and styles that update live, with a warning when the result would not scan.
### Steps
1. Add colour, dot style, corner style and margin controls
dotsOptions.color and type (square, rounded, classy), cornersSquareOptions, backgroundOptions.color, margin. Every control re-renders immediately; no Apply button.
2. Warn on low contrast
Compute the contrast ratio between foreground and background; below roughly 3:1 show a warning while still rendering. A beautiful unscannable code is the failure users ship.
### Done when
- [ ] Every control visibly changes the preview
- [ ] A low-contrast pair shows the warning and the code still renders
- [ ] A rounded-dots code still scans
## Phase 3 · Centre logo
A logo in the middle that does not break scanning.
### Steps
1. Wire an image upload to image and imageOptions
imageSize around 0.3, margin, hideBackgroundDots true. Read the file locally with FileReader; nothing is uploaded.
2. Raise error correction to H whenever a logo is present
qrOptions.errorCorrectionLevel = 'H' with a logo, back to M without. Cap the logo at roughly a quarter of the code.
### Done when
- [ ] A code with a logo at maximum size scans from 30 cm
- [ ] Removing the logo restores the previous error-correction level
## Phase 4 · Export
PNG and SVG that reopen correctly, and copy to clipboard that works in Safari too.
### Steps
1. Add download buttons with a size selector
download({ name, extension: 'png' | 'svg' }) at 512, 1024 or 2048 by re-instantiating at that size.
2. Add copy to clipboard
Always pass a Promise resolving to a Blob into ClipboardItem: Safari requires the promise form and Chromium accepts it, so one code path works everywhere. Call navigator.clipboard.write inside the click handler or the user gesture is lost.
### Done when
- [ ] PNG and SVG both download and reopen correctly
- [ ] The 2048px export is sharp
- [ ] Copy pastes an image in both Safari and a Chromium browser
## Phase 5 · Dynamic codes
Codes that point at /r/:slug so the target can change after printing.
### Steps
1. Create the codes table and the server
codes (id, slug unique, target_url, label, created_at, updated_at, active). Slugs 6+ characters from an alphabet without 0/O/1/l/I, CSPRNG-generated, or custom.
Files: `server.mjs`
2. Implement GET /r/:slug
302 to target_url. Unknown or inactive slugs redirect to FALLBACK_URL rather than 404ing.
3. Build /admin behind basic auth with CRUD
Editing a target must never change the slug. Deactivating keeps the row. Each row shows its code image and a download button.
### Done when
- [ ] A printed code for /r/:slug scans, then after editing the target scans to the new destination with no regeneration
- [ ] A deactivated code lands on the fallback page
- [ ] Creating a slug named admin or r is refused
### Watch out
- A slug is permanent. Rewriting one invalidates every printed code that uses it, which is the one unrecoverable mistake this tool can make.
## Phase 6 · Scan analytics
Counts you can explain, written after the redirect.
### Steps
1. Log scans after issuing the redirect
scans (id, code_id, scanned_at, referer, user_agent_class, ip_hash). Never before the 302.
2. Bucket bots and show totals plus a 30-day sparkline per code
Link scanners and chat previewers inflate counts otherwise.
### Done when
- [ ] A scan increments the count
- [ ] A Slack link preview is bucketed as bot and excluded
- [ ] The redirect still works with the database stopped
## Phase 7 · Deploy
Live on the domain you will keep, backed up, documented.
### Steps
1. Add /healthz, systemd, Caddy and a nightly backup
Files: `deploy/qr.service`, `Caddyfile`
2. Turn on domain auto-renew and write the README
README: the domain-is-forever warning stated once plainly, which error-correction level is used and why it changes with a logo, and when to use static instead of dynamic.
Files: `README.md`
### Done when
- [ ] A reader goes from clone to a working dynamic code on their own domain using only the README
- [ ] Auto-renew is confirmed on at the registrar
## Not in this build
- Hosted redirects on someone else's short domain. Yours must be a domain you will keep.
- Their scan-analytics product and bulk generation UI.
## After v1, if you want it
- Bulk creation from a CSV with a ZIP of PNGs
- Per-code UTM parameters appended on redirect# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file for codes and scans. DATABASE_PATH=./data/qr.db # Required. Public base of the redirect domain. Baked into every dynamic code. SITE_URL=https://go.yourdomain.com # Required. Where a deactivated or unknown code lands. A dead printed code should explain itself. FALLBACK_URL=https://yourdomain.com/this-code-is-inactive # Required · secret. openssl rand -hex 32, once. For hashing scanner IPs. IP_SALT=hex-from-openssl-rand # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
# QR Tiger · product brief ## Problem A library call with a UI. Peak "why is this a subscription." ## Product outcome A QR service you could run for a small business: static codes for free, dynamic codes on a domain with auto-renew and backups, and scan counts that separate people from bots. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Implement the core workflow described in ARCHITECTURE.md ## Explicit non-goals for v1 - Hosted redirects on someone else's short domain. Yours must be a domain you will keep. - Their scan-analytics product and bulk generation UI. - hosted dynamic-QR redirects on their domain - their scan-analytics dashboard - bulk generation UI ## Success criteria - Domain auto-renew on, with a calendar reminder a month before expiry - One restore drill performed and dated - External check on a live /r/ slug passing - README warns about domain permanence
# Build brief · QR Tiger
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a QR code generator like QR Tiger, self-hosted. Build it in phases, in
the order below. Do not write the whole tool in one pass. Finish a phase, run its
"Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- `qr-code-styling` v1.5.x for rendering, pinned to an exact version and vendored
into the repo rather than loaded from a CDN at runtime.
- Node 22 with `node:http` and `node:sqlite` for the dynamic-code service. No
Express, no framework.
- The generator page is plain HTML and vanilla JS. No React, no build step.
### Data model (create this before Phase 1)
- `codes`: id, slug (short, unambiguous alphabet, CSPRNG-generated), target_url,
label, created_at, updated_at, active (bool)
- `scans`: id, code_id, scanned_at, referer, user_agent_class, ip_hash
Store a coarse user-agent bucket and a salted IP hash, never the raw values.
A `slug` is permanent: rewriting a slug invalidates every printed code that uses
it, which is the one unrecoverable mistake this tool can make.
### Phase 1 · Static generator
Build: a single page · a text field for a URL or arbitrary text, and a live QR
preview that re-renders as you type, debounced. Instantiate `QRCodeStyling` with
`{ width, height, type: 'canvas', data }` and mount it with `.append(container)`.
Done when: typing a URL produces a code that a phone camera resolves to that
exact URL, and clearing the field leaves no broken canvas behind.
Do not build yet: styling controls, logo, export, the server.
### Phase 2 · Customization
Build: the controls · foreground and background color, dot style
(`dotsOptions.type`: square, rounded, classy), corner style
(`cornersSquareOptions`), and margin. Every control updates the preview live with
no Apply button. Warn in the UI when the foreground/background contrast drops
below a scannable ratio, because a beautiful unscannable code is the failure mode
users ship without noticing.
Done when: every control visibly changes the preview, and a low-contrast
combination shows a warning while still rendering.
### Phase 3 · Center logo
Build: an optional logo upload wired to `image` and `imageOptions`
(`imageSize`, `margin`, `hideBackgroundDots`). Raise the error-correction level
to H whenever a logo is present, and cap the logo at roughly 25% of the code.
Done when: a code with a logo at maximum size still scans on a phone from 30cm,
and removing the logo restores the previous error-correction level.
### Phase 4 · Export
Build: PNG and SVG export via `download({ name, extension })`, plus a size
selector (512, 1024, 2048) and a "copy PNG to clipboard" button. For clipboard,
always pass a `Promise<Blob>` into `ClipboardItem` rather than a resolved Blob ·
Safari requires the promise form, Chromium accepts it, so one code path works
everywhere. Call it directly inside the click handler or the gesture is lost.
Done when: PNG and SVG both download and reopen correctly, the 2048px export is
sharp, and copy-to-clipboard pastes an image in both Safari and a Chromium
browser.
### Phase 5 · Dynamic codes
Build: `GET /r/:slug` issuing a `302` to `target_url`, plus `/admin` behind basic
auth from `.env` with CRUD over codes. Editing a target must never change the
slug. Deactivating a code redirects to a configurable fallback page rather than
404ing · a dead printed code should explain itself, not error.
Done when: generating a code for `/r/:slug`, printing it, scanning it, then
editing the target and scanning again reaches the new destination with no
regeneration, and a deactivated code lands on the fallback page.
### Phase 6 · Scan analytics
Build: per-code scan logging on the redirect path (write after issuing the
redirect, never before), with totals and a 30-day count in the admin list and a
per-code sparkline as inline SVG. Filter bot user agents into a separate bucket
· link scanners and chat-app previewers will inflate counts otherwise.
Done when: a scan increments the count, a Slack link-preview fetch is bucketed as
a bot and excluded from the headline number, and the redirect still works with
the database stopped.
### Phase 7 · Deploy
Build: a `/healthz` endpoint, a nightly SQLite backup, a systemd unit, and the
README.
Done when: a reader goes from clone to a working dynamic code on their own domain
using only the README.
### Out of scope (and why)
- Hosted redirects on someone else's short domain. Yours must be a domain you
will keep · a printed code outlives the tool that made it.
- Their scan-analytics product and bulk generation UI.
### README must contain
- The warning, stated once and plainly: if this server or domain goes away, every
printed dynamic code becomes dead paper. Static codes have no such dependency ·
use dynamic only when you genuinely need to edit the target later.
- Which error-correction level is used and why it changes with a logo.# Architecture · QR Tiger ## Stack | Part | Choice | Why | | --- | --- | --- | | Rendering | qr-code-styling 1.5.x, vendored | handles dots, corners and logos; pinned and served by you, not a CDN | | Runtime | Node 22, node:http and node:sqlite | a redirect is one lookup and a 302 | | Database | SQLite | codes and scans in one file | | Hosting | A VPS behind Caddy on a domain you will keep | a printed code outlives the tool that made it | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Generator | the browser page and vendored library | Any renderer producing the same PNG/SVG; the server does not know about it | | Redirector | /r/:slug and the codes table | A serverless function reading the same table | | Analytics | scans and bucketing | Drop or replace without touching redirects | | Admin | CRUD and code images | Any UI; slugs are the contract | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Any free port; Caddy proxies to it. - `DATABASE_PATH` · required · SQLite file for codes and scans. - `SITE_URL` · required · Public base of the redirect domain. Baked into every dynamic code. - `FALLBACK_URL` · required · Where a deactivated or unknown code lands. A dead printed code should explain itself. - `IP_SALT` · required, secret · openssl rand -hex 32, once. For hashing scanner IPs. - `ADMIN_USER` · required · Any username for the basic-auth admin pages. - `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · QR Tiger product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: qr-code-styling 1.5.x, vendored, Node 22, node:http and node:sqlite, SQLite, A VPS behind Caddy on a domain you will keep. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - A slug is permanent. Rewriting one invalidates every printed code that uses it, which is the one unrecoverable mistake this tool can make.
# Delivery milestones · QR Tiger
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · Static generator
Type text, see a code, scan it.
### Steps
1. Create the project and vendor qr-code-styling
Install the pinned version and copy its browser bundle into public/vendor so nothing loads from a CDN at runtime.
Files: `public/index.html`, `public/app.js`
```sh
mkdir qr && cd qr && git init && npm init -y && npm pkg set type=module
npm install qr-code-styling@1.5.0
mkdir -p public/vendor data && cp node_modules/qr-code-styling/lib/qr-code-styling.js public/vendor/
```
2. Build the page: a text field and a live preview
new QRCodeStyling({ width: 300, height: 300, type: 'canvas', data }) then .append(container). Re-render on input with a 150 ms debounce.
### Done when
- [ ] Typing a URL produces a code a phone camera resolves to exactly that URL
- [ ] Clearing the field leaves no broken canvas behind
## M2 · Customization
Colours and styles that update live, with a warning when the result would not scan.
### Steps
1. Add colour, dot style, corner style and margin controls
dotsOptions.color and type (square, rounded, classy), cornersSquareOptions, backgroundOptions.color, margin. Every control re-renders immediately; no Apply button.
2. Warn on low contrast
Compute the contrast ratio between foreground and background; below roughly 3:1 show a warning while still rendering. A beautiful unscannable code is the failure users ship.
### Done when
- [ ] Every control visibly changes the preview
- [ ] A low-contrast pair shows the warning and the code still renders
- [ ] A rounded-dots code still scans
## M3 · Centre logo
A logo in the middle that does not break scanning.
### Steps
1. Wire an image upload to image and imageOptions
imageSize around 0.3, margin, hideBackgroundDots true. Read the file locally with FileReader; nothing is uploaded.
2. Raise error correction to H whenever a logo is present
qrOptions.errorCorrectionLevel = 'H' with a logo, back to M without. Cap the logo at roughly a quarter of the code.
### Done when
- [ ] A code with a logo at maximum size scans from 30 cm
- [ ] Removing the logo restores the previous error-correction level
## M4 · Export
PNG and SVG that reopen correctly, and copy to clipboard that works in Safari too.
### Steps
1. Add download buttons with a size selector
download({ name, extension: 'png' | 'svg' }) at 512, 1024 or 2048 by re-instantiating at that size.
2. Add copy to clipboard
Always pass a Promise resolving to a Blob into ClipboardItem: Safari requires the promise form and Chromium accepts it, so one code path works everywhere. Call navigator.clipboard.write inside the click handler or the user gesture is lost.
### Done when
- [ ] PNG and SVG both download and reopen correctly
- [ ] The 2048px export is sharp
- [ ] Copy pastes an image in both Safari and a Chromium browser
## M5 · Dynamic codes
Codes that point at /r/:slug so the target can change after printing.
### Steps
1. Create the codes table and the server
codes (id, slug unique, target_url, label, created_at, updated_at, active). Slugs 6+ characters from an alphabet without 0/O/1/l/I, CSPRNG-generated, or custom.
Files: `server.mjs`
2. Implement GET /r/:slug
302 to target_url. Unknown or inactive slugs redirect to FALLBACK_URL rather than 404ing.
3. Build /admin behind basic auth with CRUD
Editing a target must never change the slug. Deactivating keeps the row. Each row shows its code image and a download button.
### Done when
- [ ] A printed code for /r/:slug scans, then after editing the target scans to the new destination with no regeneration
- [ ] A deactivated code lands on the fallback page
- [ ] Creating a slug named admin or r is refused
### Watch out
- A slug is permanent. Rewriting one invalidates every printed code that uses it, which is the one unrecoverable mistake this tool can make.
## M6 · Scan analytics
Counts you can explain, written after the redirect.
### Steps
1. Log scans after issuing the redirect
scans (id, code_id, scanned_at, referer, user_agent_class, ip_hash). Never before the 302.
2. Bucket bots and show totals plus a 30-day sparkline per code
Link scanners and chat previewers inflate counts otherwise.
### Done when
- [ ] A scan increments the count
- [ ] A Slack link preview is bucketed as bot and excluded
- [ ] The redirect still works with the database stopped
## M7 · Deploy
Live on the domain you will keep, backed up, documented.
### Steps
1. Add /healthz, systemd, Caddy and a nightly backup
Files: `deploy/qr.service`, `Caddyfile`
2. Turn on domain auto-renew and write the README
README: the domain-is-forever warning stated once plainly, which error-correction level is used and why it changes with a logo, and when to use static instead of dynamic.
Files: `README.md`
### Done when
- [ ] A reader goes from clone to a working dynamic code on their own domain using only the README
- [ ] Auto-renew is confirmed on at the registrar
## M8 · Operate it like a product (production only)
Only for the product-builder path: know when the redirect is down, never lose the database, and keep the server patched.
### Steps
1. Add a /healthz endpoint and an external uptime check against it
Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices.
2. Write structured request logs and rotate them
One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight.
3. Back the SQLite file up off the machine nightly and test a restore
SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it.
```sh
sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'"
rclone copy /tmp/app-$(date +%F).db remote:backups/
```
4. Lock the box down
Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure.
### Done when
- [ ] Stopping the service triggers an uptime alert within a few minutes
- [ ] A restore from last night's backup contains yesterday's data
- [ ] A port scan from another machine shows only 22, 80 and 443# Operations · QR Tiger ## Backup SQLite .backup nightly. The codes table is the asset: without it every printed code is dead. ## Restore Copy back and start; verify one known slug redirects correctly. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Uptime on /healthz and on one known /r/ slug from an external checker, since a dead redirect is invisible from inside. ## Incident checklist If the domain expires, renew immediately: registrars hold a grace period. If the database is lost without backup, printed codes cannot be recovered; this is why backups are not optional. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Domain auto-renew on, with a calendar reminder a month before expiry - [ ] One restore drill performed and dated - [ ] External check on a live /r/ slug passing - [ ] README warns about domain permanence ## Launch constraint Do not market omitted QR Tiger capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file for codes and scans. DATABASE_PATH=./data/qr.db # Required. Public base of the redirect domain. Baked into every dynamic code. SITE_URL=https://go.yourdomain.com # Required. Where a deactivated or unknown code lands. A dead printed code should explain itself. FALLBACK_URL=https://yourdomain.com/this-code-is-inactive # Required · secret. openssl rand -hex 32, once. For hashing scanner IPs. IP_SALT=hex-from-openssl-rand # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
$ choose a build depth, inspect the files, then open the complete pack in your agent
xhosted dynamic-QR redirects on their domain
xtheir scan-analytics dashboard
xbulk generation UI
Don't feel like building it? These folks already made it free.
MMini QRA tidy QR workshop that generates, scans and batches without asking for a credit card or your life story.open source↗all 3 free alternatives to QR Tiger →· no votes, no pay-to-list · just what's real
QR Tiger pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | Unlimited static QR codes; 3 dynamic QR codes; 500 scans per dynamic code. |
| regular | $7/user | $5.42/user | 12 dynamic QR codes; 5 MB file upload; 500 API requests/month; 1 user. |
| advanced | — | $16/user | 200 dynamic QR codes; 10 MB file upload; 3,000 API requests/month. |
| premium | — | $37/user | 600 dynamic QR codes; 20 MB file upload; 10,000 API requests/month; 1 white-label domain. |
| professional | — | $89/workspace | 1,200 dynamic QR codes; 60 MB file upload; 1 additional user. |
| enterprise | custom | — | Custom dynamic-code, API, user and domain limits. |
free tierunlimited static QR codes; 3 dynamic QR codes; 500 scans per dynamic code
billingRegular offers monthly + annual; Advanced, Premium and Professional are annual-only; Enterprise quote-based
hidden costsdynamic codes and advanced analytics depend on an active subscription; upgrades and refunds can be prorated; higher API, user and white-label needs require a higher plan
verified 2026-08-13 · source ↗
Vibecode QR Tiger
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal QR Tiger replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does QR Tiger cost?
QR Tiger costs about $7/month (Regular, checked 2026-08-13), which is $84 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing QR Tiger?
Honestly: hosted dynamic-QR redirects on their domain; their scan-analytics dashboard; bulk generation UI. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to QR Tiger?
Yes: Mini QR (A tidy QR workshop that generates, scans and batches without asking for a credit card or your life story.) QR TIGER Free (The same generator gives away unlimited static codes that never expire; dynamic codes stop at three and 500 scans each.) QRCode Monkey (Static QR codes, unlimited scans and useful exports; tracking is the bit they keep for paying customers.) All 3 curated free alternatives are at vibecodeit.com/qr/alternatives. The prompt is for when you want it exactly your way.