Skip to content
sub12

Log the card. Run the league. Know what your gear actually does.

An open-source target shooting companion for the UK airgun benchrest community — 25-shot score cards, gear tracking, leagues and clubs, and pellet testing that measures your groups from a photograph.

CI Security Android Licence: AGPL v3 Go React PRs welcome

Try it · Install your own · Contribute · Security · Changelog

Developed by EyUp.io · AGPL-3.0 · self-host it, fork it, run it for your club


What it does

Score cards that survive the evening

A 25-shot card, captured shot by shot, with target presets and a photo of the card itself. Then — and this is the part most tools get wrong — the card's context stays changeable. Shot it against the wrong league? Round filled up before you submitted? Move it. Nobody re-shoots a card because software filed it badly.

Pellet testing from a photograph

Photograph the target, and sub12 finds the holes, measures the group and scores its own confidence in the reading. Manual placement is there when detection is wrong. Then compare tins, chart the timeline, run batch reports across rifle/pellet combinations, and put a result on the public leaderboard.

Leagues that a volunteer can actually run

Seasons and rounds get edited far more often than created, so all of it is full CRUD — rename, re-date, archive, delete. Score verification with member confirmations, moderator amend/reject, and a complete audit trail. Owners delegate specific capabilities rather than handing over the keys.

Clubs, events and the people

Club profiles with a real-world identity — address, map pin, disciplines, facilities, opening hours. Events other people enter, with delegated scorers and CSV results. Follows, an activity feed, comments, achievements, and blocks and mutes that are applied in the query rather than hidden in the client.

Dashboard Score card capture Pellet testing
Dashboard Score card capture Pellet testing
My cards Gear Club page
Your cards Gear Clubs
The full feature list

Scoring & gear

  • 25-shot cards with per-shot entry, target presets, image attachments and drafts
  • A card's context (personal / league / club / event) is changeable after capture
  • Rifle and pellet inventories with images, plus built-in catalogs of common models
  • Opt-in anonymised gear comparison — your make and model against everyone else's, suppressed entirely below three contributing owners
  • Personal trends, rifle-specific stats and historical charts
  • A gallery of every photo you've uploaded, with submit and withdraw actions

Pellet testing

  • Image-based group measurement with automatic hole detection
  • Manual hole placement when detection gets it wrong
  • Per-test confidence scoring, so a bad photo reads as a bad photo
  • Compare, timeline, batch reports and combo analytics across pellets and rifles
  • Public leaderboard, no login required
  • Ballistics helpers for downrange calculations

Leagues

  • Seasons and rounds with full CRUD; archiving retires a season that's been shot
  • Standings, configurable scoring, join requests
  • Score verification: member confirmations, moderator verify / amend / reject / reopen, with an audit trail
  • Delegated moderator capabilities — the owner grants specific duties, and a control the grant doesn't cover is drawn read-only with the reason

Clubs & events

  • Club profiles: postal address, map pin, disciplines, distances, facilities, membership and visitor info, opening hours
  • A club's authority reaches the leagues it hosts
  • Events addressed by slug, with participants, guests, invitations, delegated scorers, scoreboard and results.csv

Social & moderation

  • Follow / unfollow, activity feed, posts, comments, likes, achievements
  • Blocks and mutes applied inside the feed query, symmetrically
  • Reports on posts, comments and users; an admin decision queue; a background sweeper that gives the author a grace period to amend first
  • Group announcements from leagues, clubs, events and the platform

Notifications

  • One row per recipient, delivered in-app, by push and by email — each gated on the recipient's own preference
  • Validation requests go to whoever can actually act, with volunteers able to opt in to reviewing other shooters' cards

Accounts

  • Email/password with JWT sessions and refresh tokens
  • TOTP two-factor with bcrypt-hashed backup codes
  • Password reset, avatar upload, email change

Admin

  • A first-run setup wizard, and branding a self-hoster owns: name, tagline, logo, accent colour (light and dark), boot theme, welcome copy, and whether the installation presents as a community or as one club's own site
  • Users, leagues, clubs, events, gear analytics, FAQ, categories, feature board
  • SMTP settings and an email template editor with live preview
  • Sitemap & SEO reporting with IndexNow submission
  • Encrypted S3 backups — a run without a passphrase fails rather than uploading plaintext
  • Activity simulation for populating a demo instance

Platform

  • Self-hostable with its own identity — see the setup wizard
  • Installable PWA, offline-aware, with a queued score outbox
  • Native iOS and Android shells from the same bundle: share sheet, camera, geolocation, deep links, push, haptics
  • Dark mode, and a design system with motion tokens and a prefers-reduced-motion guard

See it in action

Short screen recordings of the real app, narration baked into the frames. The players below stream the webm files out of this repository, so they play right here in the README — no site visit, no account. If your viewer won't play webm inline, every clip and poster is a plain file under frontend/public/demos/.

Showcase

A 25-shot card, start to finish
Tap every shot; the totals, averages and personal bests take care of themselves.
open the file · poster

Photograph the target, read the group
Auto-detected holes, group size in mm and MOA — no ruler.
open the file · poster

Your league, live standings
Form, history, and a table that moves the moment a card is verified.
open the file · poster

Know exactly what's working
Trends, distributions and per-rifle analytics across every card you shoot.
open the file · poster

How-to guides

Add your rifle and pellets
Pick your gear from the catalog and the specs fill themselves in.
open the file · poster

Log your first score card
25 shots, a rifle, a pellet — one tap per shot and the card keeps count.
open the file · poster

Test pellets: photograph and measure a group
Photograph the target and sub12 reads the group in mm and MOA.
open the file · poster

Join a league and submit a card
Enter a join code, send a card to the open round, watch the standings.
open the file · poster

Run a league: seasons, rounds, verification
The organiser's weekly loop, from opening a round to verifying scores.
open the file · poster

The same recordings play in-app on the Help page, and in the "See it in action" section of the landing page — before you sign in. Nothing is edited by hand: every video is re-recorded with one command (./scripts/record-demos.sh) whenever the UI it shows changes, and docs/demo-recordings.md is the production standard and storyboard catalog.

Installation

One command, and it will ask you what you want:

git clone https://github.com/eyupio/sub12.git
cd sub12
./scripts/install.sh

The installer checks your prerequisites, generates real secrets (never the placeholders in .env.example — those are public now, see Security), writes .env with mode 600, prepares the backup directory with the ownership the container needs, runs migrations and waits until the stack answers. It offers three modes:

Mode What you get Who it's for
Local development Postgres + Redis in Docker; you run the Go API and Vite yourself, with hot reload and seed data Changing the code
Self-host The whole stack from published container images Running it for your club
Self-host, from source Same, but images built from your checkout You've changed something, or you want to verify the build

Useful flags:

./scripts/install.sh --check                        # prerequisites only, changes nothing
./scripts/install.sh --mode dev --seed --yes        # unattended dev setup
./scripts/install.sh --mode self-host \
    --site-url https://shoot.myclub.org --port 3000 --yes
./scripts/install.sh --no-start                     # write config, start nothing

Re-running it is safe: every secret already in your .env is preserved, not rotated, and the old file is backed up.

On Windows, run it under WSL2 — which Docker Desktop needs anyway. There is no PowerShell installer yet; the manual steps below work from any shell.

First run: the setup wizard

The first time you open a fresh deployment you land on /setup instead of a sign-in form, because there is nothing yet to sign in as. If this deployment is replacing an earlier one, choose its encrypted .pgdump.gz.enc backup, enter the backup passphrase, and restore it directly from this first screen. When the restore completes, sign in with an account from the backup; there is no need to create a temporary administrator first.

For an entirely new deployment, continue through six short steps:

  1. Deployment shape. A community — anyone signs up, discovers clubs and joins leagues, the way sub12.io runs — or one club, where the whole installation is your club's own site. In single-club mode the wizard creates the club, the nav carries its name instead of "Clubs", and the club directory goes straight through to it.
  2. Identity. Name, tagline, welcome heading and message (both shown on the sign-in screen), support email, and whether registration is open to anyone or accounts are created by an administrator.
  3. Look. An accent colour — six presets or your own hex, separately for light and dark mode — which theme people start in, and whether they may switch. The whole gold family in the design system is derived from the one colour you pick, and the wizard repaints live as you choose.
  4. Administrator. The first account, promoted to platform admin.

The wizard runs exactly once. It closes the moment it creates that first administrator or restores a database containing one, and can never be reopened — the endpoints behind it refuse a second submission whether or not anyone loads the page. Do it before the site is in front of anyone.

Everything except the account is editable afterwards from Admin → Branding, which is also the only place to upload a logo (that needs an account, and setup runs before there is one).

Upgrading an existing deployment skips all this: a database with users in it is already set up, so nothing changes.

A rebranded deployment still carries Powered by SUB12 · by EyUp.io · Source in its footer. Those links come from server-side constants rather than from the settings you edit, so there is no branding choice that removes them: the Source link is how AGPL-3.0 section 13 is discharged, and the other two say what the software is and who wrote it. Put your club's name over the door — the building still gets a plaque.

Prerequisites

Needed for
Docker + Compose v2 All modes — Postgres and Redis
Go 1.26+ Local development only
Node 26+ Local development only
curl, make, openssl Health checks, migrations, secret generation

Doing it by hand

Local development, manually
cp .env.example .env                       # then edit it
make dev                                   # Postgres + Redis
cd backend && make migrate-up              # schema
cd backend && make seed                    # optional: test accounts and data
cd backend && make run                     # API on :8080
cd frontend && npm install && npm run dev  # app on :5173

Seeded accounts, development only:

Email Password Role
dev@sub12.local password123 User
admin@sub12.local password123 Admin
Full Docker stack, manually
cp .env.example .env
# Edit .env: set a real JWT_SECRET and DB_PASSWORD, set SITE_URL,
# set ENV=production. The backend refuses to start otherwise — see Security.

mkdir -p ./data/backups
sudo chown 10001:10001 ./data/backups && sudo chmod 750 ./data/backups

docker compose up -d
docker compose logs backend   # a rejected config value is printed at startup

The backend container runs as UID 10001 and writes AES-encrypted backup archives to /var/lib/sub12/backups, bind-mounted to ./data/backups. Pre-creating that directory with the right ownership is what lets admin backup runs succeed — without it they fail with mkdir /var/lib/sub12: permission denied, which you'd rather not discover at the moment you need a backup.

Admin → Backups and the first-run restore screen accept encrypted restore uploads up to 512 MiB and allow up to 30 minutes for the upload and pg_restore to complete. A reverse proxy or CDN in front of sub12 must allow the same body size and duration. When an upstream service imposes a lower limit, reach ${WEB_PORT:-3000} through a trusted local connection or SSH tunnel for the restore; the backend port should remain private.

Running it behind a reverse proxy

The frontend container serves the SPA and proxies /api/ to the backend internally, so it is the only upstream your proxy needs. The backend port is never published to the host.

your proxy (TLS) ──▶ localhost:${WEB_PORT:-3000} ──▶ frontend:8080 ──▶ backend:8080

Set SITE_URL to the URL people actually visit. Every link sent by email or push is built from it, and PASSWORD_RESET_URL, EVENT_INVITATION_URL and DEFAULT_AVATAR_URL are all derived from it — so one setting retargets the lot. Set CORS_ORIGIN to the same value.

Forks running their own images set IMAGE_REPO=ghcr.io/your-org rather than editing docker-compose.yml.

Upgrading

docker compose pull && docker compose up -d

Migrations run on startup and are idempotent. Check docker compose logs backend afterwards.

Security

sub12 handles accounts, uploads and a public API, so a few things are worth stating plainly. The full picture is in SECURITY.md, including how to report a vulnerability privately — please don't open a public issue for one.

The placeholders in .env.example are public. They always were in spirit; now the repository is open they demonstrably are. A deployment that keeps the example JWT_SECRET can have tokens forged against it by anyone who reads this repo — including admin tokens, with no password and no login involved. So the backend refuses to start in ENV=production when it finds:

  • a JWT_SECRET or DB_PASSWORD that is one of the published placeholders, or a JWT_SECRET shorter than 32 characters
  • DB_SSLMODE allowing an unencrypted connection, unless you set DB_ALLOW_INSECURE_LOCAL_NETWORK=true to state that the database isn't reachable off-host — which is true of the shipped Compose topology and not of a managed database somewhere else
  • a CORS_ORIGIN that is *, empty, or pointing at localhost
  • a weak ADMIN_PASSWORD while SEED_ADMIN=true
  • any user-facing URL still pointing at localhost

It reports every problem at once rather than one per restart. If you see that error, the guard is working — ./scripts/install.sh produces a config that passes it.

What we do already: bcrypt password hashing, TOTP two-factor, per-IP rate limiting on every password-bearing endpoint, parameterised SQL throughout, a script-src 'self' CSP with HSTS in production, non-root containers, and CodeQL

  • govulncheck + npm audit + secret scanning in CI.
make security   # govulncheck and npm audit, the same as CI runs

Architecture

┌──────────────┐        ┌──────────────┐        ┌──────────────┐
│  React PWA   │        │   Go API     │        │ PostgreSQL 16│
│  Capacitor   │──HTTP──▶│  Chi + pgx   │────────▶│  + Redis 7   │
│  iOS/Android │        │              │        │              │
└──────────────┘        └──────┬───────┘        └──────────────┘
                               │
                     ┌─────────┴─────────┐
                     │ SMTP · FCM push   │
                     │ S3 backups · OG   │
                     │ image rendering   │
                     └───────────────────┘

The backend is strictly layered — handler → service → repository. Handlers write HTTP responses, services hold business logic and return domain errors, repositories run pgx queries. No ORM.

backend/
  cmd/api/                  entrypoint
  internal/
    api/handler/            HTTP handlers, one file per domain (~45)
    api/middleware/         auth (JWT → context), logging, rate limiting
    api/router.go           every route in the API, in one file
    config/                 env-based config + production Validate()
    db/migrations/           sequential SQL, embedded and run on startup
    model/                   domain types, plus the pure rules worth unit-testing
    repository/             pgx data access, one file per domain (~40)
    service/                business logic, one file per domain (~44)
frontend/
  src/
    api/                    typed fetch wrappers, one per domain
    components/             shared UI plus per-feature subfolders
    pages/                  route pages (~88)
    routeTree.tsx           the one route tree — public vs authed lives here
    store/                  Zustand: auth, theme, toast, navigation
    utils/                  ballistics, hole detection, share, push, routing
e2e/                        Playwright suite
docs/                       long-form design notes
scripts/                    install, mobile build, e2e, demo recording

__tests__/ folders sit next to the code they cover.

CLAUDE.md is the single source of truth for architecture and conventions — including the reasons behind rules that look arbitrary until you know the outage that produced them. Read it before a substantial change.

Stack

Layer Technology
Backend Go 1.26, Chi v5, pgx v5, zerolog, golang-jwt v5, go-redis v9
Database PostgreSQL 16 with golang-migrate (embedded SQL)
Cache Redis 7
Frontend React 19, TypeScript 6, Vite 8, TanStack Router + Query, Zustand 4
Styling Tailwind CSS v3, Lucide icons, Recharts
Mobile Capacitor 6 (iOS / Android), PWA via vite-plugin-pwa
Also fogleman/gg renders share cards, pquerna/otp backs 2FA, minio-go ships encrypted backups
Testing Go test + testify, Vitest + Testing Library, Playwright
CI/CD GitHub Actions → GHCR images

Development

make check      # everything the PR gate runs
make security   # govulncheck + npm audit
make help       # all targets

Or by hand:

cd backend  && make test    # go test -race -count=1 ./...
cd backend  && make lint    # go vet ./...
cd frontend && npm run check && npm run lint && npm test && npm run build

Two things that catch people:

  • npm run check is tsc -b, not tsc --noEmit. The root tsconfig.json is a solution file with "files": [], so tsc --noEmit resolves no inputs and exits 0 on any codebase, however broken. Only build mode follows the project references.
  • Backend tests that need a database skip silently unless DB_HOST is set. make dev gives you one. Without it, a green run may have skipped exactly the tests covering your change.

The Playwright suite is not in the PR gate. Run it yourself when you touch score capture, leagues, events or clubs — ./scripts/e2e.sh, or see E2E_TESTING.md.

Database migrations

cd backend
make migrate-create NAME=add_foo   # picks the next sequence number for you
make migrate-up
make migrate-down
make migrate-lint                  # duplicate prefix check, as CI does

Always use make migrate-create — never hand-write the file. All DDL must be idempotent (IF NOT EXISTS, ON CONFLICT DO NOTHING, EXCEPTION WHEN duplicate_object), every .up.sql needs a .down.sql that fully reverses it, and one concern per migration. These rules exist because we've had production outages from migration conflicts.

Configuration

Two variables are required: DB_PASSWORD and JWT_SECRET. Everything else has a default.

The one worth understanding is SITE_URL — the canonical public host. PASSWORD_RESET_URL, EVENT_INVITATION_URL and DEFAULT_AVATAR_URL are all derived from it when unset, so one change retargets every link sent by email or push.

See .env.example for the annotated list and CLAUDE.md for every variable and its default.

Mobile apps

The web build and the native iOS / Android apps ship from one codebase — the same frontend/dist/ bundle wrapped by Capacitor 6.

Store releases are still being finished. Today the PWA installs to the home screen on any phone and gives you the whole app, which is why the landing page points people there rather than at a sideload. See docs/mobile-store-readiness.md for what's outstanding.

cd frontend
npm run build:mobile     # tsc -b && vite build && cap sync
npm run run:android      # build + launch on emulator/device
npm run run:ios          # macOS only

CI builds the Android app on every push and keeps a rolling android-latest pre-release, so a fixed URL always points at the newest main build (.../releases/download/android-latest/sub12.apk). That exists for testing and for self-hosters building their own; it isn't the recommended way to install sub12 yet.

Trigger Variant Where it lands
Pull request debug Workflow artifact
Push to main release Rolling android-latest pre-release + artifact
Tag v* release Attached to that version's Release

VITE_ANDROID_APK_URL retargets that link for forks and staging builds; a fork that modifies the code should also set VITE_SOURCE_URL so the footer's Source link points at its own repository.

What the native shell adds over the PWA
  • API + share hosts — a native WebView is served from a local origin, so API calls and user-facing links target the canonical host instead (VITE_API_URL, VITE_SITE_URL)
  • Native share sheet — Android WebViews don't expose navigator.share
  • Native camera and photo picker for target capture
  • Native geolocation for tagging cards and pellet tests
  • Deep linkinghttps://sub12.io/... opens the matching in-app screen
  • Push notifications via FCM; set FCM_CREDENTIALS_JSON to enable delivery
  • Auth — the SameSite=Lax refresh cookie isn't delivered cross-site from a WebView, so native persists the refresh token and passes it explicitly
  • Native chrome — status bar, splash, safe areas, Android back button, and the service worker skipped to avoid serving a stale shell after an app update
  • Haptics on nav, tabs, the capture FAB, likes and toasts

Signing matters. Android identifies an installed app by (package name, signing certificate), so a key that changes between builds makes every new APK refuse to install over the last one — and the phone silently carries on running the build it already has. Set ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD to sign with a stable release key. Without them CI mints one and reuses it from the Actions cache; a cache eviction rotates it, so the release notes carry the signer's SHA-256 fingerprint.

iOS needs a paid Apple Developer membership to produce an installable build. Without signing secrets the workflow archives unsigned as a compile check only.

See frontend/README.md for prerequisites and the full asset workflow.

Contributing

Contributions are genuinely welcome — and a bug report from the range is as useful as a pull request.

Good first steps: open issues, documentation fixes, or testing an APK on a device you own and telling us what broke.

Licence

GNU Affero General Public License v3.0 — see LICENSE.

You may run, study, modify and redistribute sub12 freely. The one obligation worth knowing about: AGPL section 13 means that if you run a modified sub12 as a network service, you must offer its source to the people using it over that network. That's why the app footer carries a Source link — a fork should point VITE_SOURCE_URL at its own repository rather than removing it.

Brand assets in brand/ — the SUB12 name, logo and reticle — are not covered by the code licence. Fork the software freely; please give your fork its own name and mark.


Developed by EyUp.io

Built for shooters, with shooters. If sub12 is useful to your club, a ⭐ helps other people find it.

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages