A Free Tool that allows you to create QR codes for your Website and edit those…
not yet reviewed by publik
OpenQR
open source · on publik
Every step written out, for people who have never opened a terminal. Pick the setup you have.
README
Open in GitHubDynamic QR codes without the subscription.
OpenQR is a self-hostable alternative to paid dynamic QR-code platforms. Create a QR code once, point it at any URL, and change the destination whenever you like — the printed code never needs to be regenerated. Scan analytics are built in and privacy-friendly by design.
QR code → https://your-openqr.com/r/a8F92k → https://example.com
(permanent) (changeable)
A static QR code encodes one URL forever — change the URL and you have to reprint everything. A dynamic QR code encodes a short link owned by the platform: the link is permanent, but where it points can change at any time.
OpenQR gives you the dynamic part without the monthly fee and without trusting a third party with your traffic. It is a complete, runnable web application:
| Area | What you get |
|---|---|
| Dynamic redirects | Every QR code gets a permanent short code (/r/a8F92k); its destination is editable at any time |
| QR customization | Foreground/background colors, export size (256–2048 px), error correction level (L/M/Q/H) with a built-in scannability guard |
| Downloads | Print-ready PNG and vector SVG exports |
| Analytics | Scans over time, unique visitors, top countries, device types, browsers, operating systems, referrers — with Today / 7 days / 30 days / All-time filters |
| Privacy | No cookies, no fingerprints, no raw IPs (salted SHA-256 hashes only, used solely for unique-scan counting) |
| Auth | Email + password registration/login, bcrypt hashing, DB-backed sessions, protected dashboard |
| Security | Zod validation, URL allow-listing, per-user authorization, rate limiting, secure cookies, CSRF-resistant server actions |
| Self-hosting | Single docker compose up -d, PostgreSQL included, automatic schema sync on boot |
Placeholders — replace with real captures of your instance.
| Landing page | Dashboard |
|---|---|
| docs/screenshots/landing.png | docs/screenshots/dashboard.png |
| QR detail page | Analytics |
|---|---|
| docs/screenshots/qr-detail.png | docs/screenshots/analytics.png |
The easiest way to run OpenQR in production:
git clone git@github.com:your-username/openqr.git
cd openqr
cp .env.example .env
# Edit .env — set POSTGRES_PASSWORD, NEXT_PUBLIC_APP_URL and IP_HASH_SALT.
docker compose up -d
This starts two services:
Dockerfile)On startup the app container syncs the database schema automatically (prisma db push, idempotent — it only applies what is missing). Disable with OPENQR_AUTOMIGRATE=false if you manage migrations yourself.
Then visit http://your-server:3000 (or whatever APP_PORT you set).
docker compose logs -f app # follow logs
docker compose down # stop (data survives in the postgres-data volume)
docker compose down -v # stop and DELETE all data
OpenQR ships two Prisma schemas with identical models:
| File | Provider | Used for |
|---|---|---|
prisma/schema.prisma | SQLite | Zero-config local development |
prisma/schema.postgres.prisma | PostgreSQL | Docker / production self-hosting |
The Dockerfile swaps the Postgres schema in at build time, so containers always run on PostgreSQL.
# SQLite (local dev)
bun run db:push
# PostgreSQL (when DATABASE_URL points at Postgres)
bun run db:push:postgres
bun run db:generate:postgres
Schema highlights — all hot paths are indexed:
model QRCode {
shortCode String @unique // redirect lookups
userId String
@@index([userId]) // dashboard listing
@@index([createdAt])
}
model ScanEvent {
qrCodeId String
createdAt DateTime
ipHash String?
@@index([qrCodeId]) // per-code analytics
@@index([createdAt]) // date-range filters
@@index([qrCodeId, createdAt]) // combined queries
@@index([ipHash]) // unique-scan counts
}
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL | yes | — | Postgres connection string in Docker (postgresql://user:pass@db:5432/openqr?schema=public) or a SQLite file for local dev (file:./db/custom.db) |
NEXT_PUBLIC_APP_URL | recommended | derived from request headers | Public base URL used to build short links encoded in QR codes. Set it to the URL visitors actually reach |
IP_HASH_SALT | yes (prod) | insecure default | Secret salt for hashing IPs. Generate with openssl rand -hex 32. Changing it invalidates future unique counts only |
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB | yes (Docker) | openqr / — / openqr | Credentials for the compose db service |
APP_PORT | no | 3000 | Host port for the app container |
OPENQR_AUTOMIGRATE | no | true | Run prisma db push on container start |
Never expose database credentials to the client: all database access happens in server-only modules (src/lib/db.ts and friends), and NEXT_PUBLIC_* variables are limited to non-sensitive values.
.env.example to .env. Set:
NEXT_PUBLIC_APP_URL=https://qr.yourdomain.com (must match what users' phones resolve — QR codes encode this host)POSTGRES_PASSWORD and IP_HASH_SALT to long random stringsx-forwarded-proto/x-forwarded-host, so standard proxy headers work. Secure cookies turn on automatically when NODE_ENV=production and the request is HTTPS.docker compose up -d --build.postgres-data volume (docker run --rm -v openqr_postgres-data:/data -v $PWD:/backup alpine tar czf /backup/data.tgz /data).Scaling notes:
src/lib/rate-limit.ts to a Redis-backed implementation.{NEXT_PUBLIC_APP_URL}/r/{shortCode}. Because that URL is permanent, the printed code is permanent.GET /r/{shortCode} looks up the code by its unique index, records a scan event, and responds with a 302 redirect to the current destinationUrl.302 (temporary) redirect is deliberate: 301s are cached aggressively by browsers and proxies, which would break the "change destination anytime" guarantee. Responses are sent with cache-control: no-store.http(s) URLs and cannot target localhost/private IP ranges.Every redirect records a ScanEvent with:
| Field | Source | Privacy note |
|---|---|---|
createdAt | server clock | — |
country | CDN/edge headers (cf-ipcountry, x-vercel-ip-country, …) when present | 2-letter country code only, null otherwise |
deviceType / browser / os | User-Agent parsing (ua-parser-js) | Coarse categories only |
referrer | Referer header, origin + path only, internal referrers dropped | Query strings (often containing PII) are stripped |
ipHash | HMAC-SHA256(IP, IP_HASH_SALT), truncated | The raw IP is never stored and cannot be recovered; used exclusively to count unique visitors |
What we deliberately do not store: raw IPs, full user-agent strings, cookies, fingerprints, or anything that identifies an individual. Because analytics are aggregates, they are GDPR-friendly by construction — no consent banner needed for counting.
Date filters (Today / 7 days / 30 days / All time) apply to every widget; hourly buckets are used for "Today", daily for the 7/30-day views and monthly for long all-time histories.
src/
├── app/
│ ├── (auth)/login, register # Authentication pages
│ ├── dashboard/ # Protected app (overview, QR codes, analytics, settings)
│ ├── r/[shortCode]/route.ts # Dynamic redirect endpoint
│ ├── api/qr/[id]/download/ # PNG/SVG export API
│ └── page.tsx # Landing page
├── actions/ # Server actions (auth, QR CRUD)
├── components/ # UI (shadcn/ui + feature components)
└── lib/ # auth, db, queries, rate-limit, scan-tracking, qr, validation
prisma/
├── schema.prisma # SQLite (local dev)
└── schema.postgres.prisma # PostgreSQL (production)
| Symptom | Likely cause & fix |
|---|---|
| "Invalid Server Actions request" on form submit | Your proxy rewrites the Host header. Pass the original host through (proxy_set_header Host $host; / Caddy header_up Host {host}), or add your public domain to experimental.serverActions.allowedOrigins in next.config.ts |
| App container restarts in a loop | The database isn't reachable yet — check docker compose logs db and that POSTGRES_PASSWORD in .env matches what the db service uses |
QR codes point at localhost:3000 in production | NEXT_PUBLIC_APP_URL is unset — set it to the public URL visitors actually reach and recreate the affected codes |
| Unique-scan counts reset to zero | IP_HASH_SALT changed — keep it stable; it is the key that makes repeat visitors recognizable |
| Countries show as "Unknown" | No CDN geo header was present. Behind Cloudflare/Vercel the cf-ipcountry / x-vercel-ip-country headers are read automatically |
docker compose down -v deleted everything | That flag removes the postgres-data volume by design — restore from your backup (see Production deployment) |
All rights reserved. You are free to run, modify, and self-host OpenQR for your own use.