A self-hosted vinyl record collection manager with a GraphQL API.
Supply chain verification: Release container images are signed with cosign and include SLSA provenance and an SPDX SBOM. See SECURITY.md for verification instructions.
Vynilino lets you catalogue, search, and manage your vinyl record collection from a single self-hosted instance. It connects to the Discogs database for metadata and cover-art lookup, stores everything in a local SQLite file, and exposes a GraphQL API consumed by a lightweight built-in web UI.
Key properties:
- Self-hosted first — no cloud account required; all data stays on your server.
- Single binary — the Go backend embeds the UI assets; one file to deploy.
- Lightweight — < 30 kB gzipped JS bundle (Alpine.js + Tailwind CSS 4); no React/Vue runtime.
- OIDC-ready — integrate with any standards-compliant identity provider.
- Supply-chain secure — SLSA Level 2 provenance, keyless cosign signatures, and SPDX SBOM on every release.
Vynilino was vibe engineered: the entire codebase was designed and implemented through an AI-assisted, spec-driven workflow powered by OpenSpec.
Vibe engineering is a development approach where a human collaborates with an AI assistant (here, Claude Code) to go from idea to working software. The human describes intent and reviews outcomes; the AI writes code, runs tests, and iterates. The key discipline that makes this work at scale is spec-driven development.
OpenSpec is a lightweight, file-based specification system that lives inside the repository (openspec/). Each feature starts as a proposal → design → specs → tasks chain. The AI reads these documents, implements the tasks, and marks them complete. The result is:
- A full audit trail of every design decision inside the repo.
- Human-readable specs that explain why code exists, not just what it does.
- A repeatable workflow that a new contributor (or AI session) can pick up at any point.
openspec/
config.yaml # project-level OpenSpec config
specs/ # current, living specifications per domain
changes/
archive/ # completed changes, one directory per change
YYYY-MM-DD-<slug>/
.openspec.yaml # change metadata
proposal.md # what and why
design.md # how (architecture, trade-offs)
specs/ # per-domain spec deltas for this change
tasks.md # implementation checklist
Archived changes are the project's decision log. Reading them in chronological order tells the full story of how the project evolved.
# 1. Generate a 32-byte symmetric key (hex-encoded)
export VYNILINO_TOKEN_KEY=$(openssl rand -hex 32)
# 2. Set allowed origins for your SPA
export VYNILINO_ALLOWED_ORIGINS="http://localhost:3000"
# 3. Start the service
docker compose up -dThe service is now available at http://localhost:8080.
Vynilino binds to plain HTTP on :8080. For production, put Caddy in front to handle TLS termination and automatic certificate management via Let's Encrypt.
vinyl.example.com {
reverse_proxy localhost:8080 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode gzip
}Caddy proxies WebSocket upgrades (GraphQL subscriptions) automatically — no extra configuration needed.
Update these environment variables to match your public domain:
VYNILINO_OIDC_REDIRECT_URL=https://vinyl.example.com/oidc/callback
VYNILINO_ALLOWED_ORIGINS=https://vinyl.example.comUse Caddy's built-in CA for a locally-trusted certificate (useful for LAN deployments or development over HTTPS):
vinyl.local {
tls internal
reverse_proxy localhost:8080
}vinyl.example.com {
tls /path/to/cert.pem /path/to/key.pem
reverse_proxy localhost:8080
}| Variable | Default | Description |
|---|---|---|
VYNILINO_LISTEN_ADDR |
:8080 |
TCP address to bind |
VYNILINO_ENV |
production |
development enables playground + verbose logs |
VYNILINO_DB_PATH |
./data/vynilino.db |
SQLite database file path |
VYNILINO_MEDIA_DIR |
./data/media |
Directory for cover art files |
VYNILINO_TOKEN_KEY |
(required) | 32-byte hex-encoded PASETO symmetric key |
VYNILINO_TOKEN_KEY_NEW |
(unset) | New key during rotation bridge period; see key rotation runbook |
VYNILINO_SINGLE_OWNER |
true |
Only one user account allowed when true |
VYNILINO_BOOTSTRAP_TOKEN |
(unset) | When set, required as a one-time token for first-user registration |
VYNILINO_ALLOWED_ORIGINS |
http://localhost:3000,... |
Comma-separated CORS allowed origins |
VYNILINO_PLAYGROUND |
false (true in development) |
Serve GraphQL Playground at /playground |
VYNILINO_INTROSPECTION |
false (true in development) |
Enable GraphQL introspection |
VYNILINO_OIDC_ISSUER |
(unset — OIDC disabled) | OIDC provider issuer URL (enables OIDC when set) |
VYNILINO_OIDC_CLIENT_ID |
(unset) | OIDC application client ID |
VYNILINO_OIDC_CLIENT_SECRET |
(unset) | OIDC application client secret |
VYNILINO_OIDC_REDIRECT_URL |
(unset) | Callback URL registered with the OIDC provider |
VYNILINO_OIDC_AUTO_REDIRECT |
false |
When true, GET /login redirects directly to the OIDC provider |
VYNILINO_DISCOGS_TOKEN |
(unset) | Personal access token for higher Discogs API rate limits |
VYNILINO_BEHIND_PROXY |
false |
Trust X-Forwarded-For / X-Real-IP headers (set only behind a known reverse proxy) |
VYNILINO_TLS_CERT |
(unset) | Path to TLS certificate PEM file (enables native TLS) |
VYNILINO_TLS_KEY |
(unset) | Path to TLS private key PEM file |
VYNILINO_BACKUP_HMAC_KEY |
(unset) | HMAC-SHA256 key for backup authenticity signing/verification |
GraphQL endpoint: POST /graphql
WebSocket (subscriptions): GET /graphql (upgrade)
# Register (first user becomes admin in single-owner mode)
mutation {
register(email: "you@example.com", password: "YourPass1!") {
accessToken
refreshToken
expiresIn
}
}
# Login
mutation {
login(email: "you@example.com", password: "YourPass1!") {
accessToken
refreshToken
}
}Pass Authorization: Bearer <accessToken> on all subsequent requests.
mutation {
createRecord(input: {
title: "The Dark Side of the Moon"
artist: "Pink Floyd"
year: 1973
format: LP
condition: NEAR_MINT
}) {
record { id title artist }
duplicateWarning
}
}
query {
records(first: 20, filter: { artist: "Floyd" }, sort: { field: YEAR, direction: DESC }) {
edges { node { id title year condition } }
pageInfo { hasNextPage endCursor }
totalCount
}
}curl -X POST http://localhost:8080/media/cover-art \
-H "Authorization: Bearer $TOKEN" \
-F "file=@cover.jpg" \
-F "recordId=<record-id>"# Export as JSON
curl -o collection.json \
-H "Authorization: Bearer $TOKEN" \
http://localhost:8080/export/json
# Export as CSV
curl -o collection.csv \
-H "Authorization: Bearer $TOKEN" \
http://localhost:8080/export/csv
# Import from CSV (including Discogs export format)
curl -X POST http://localhost:8080/import/csv \
-H "Authorization: Bearer $TOKEN" \
-F "file=@discogs_export.csv"All data is stored in two locations:
- Database:
VYNILINO_DB_PATH(single SQLite file) - Cover art:
VYNILINO_MEDIA_DIR(flat directory per user)
Vynilino ships a backup subcommand that creates a compact, verified snapshot using SQLite's VACUUM INTO and optionally signs it with HMAC-SHA256.
# Create a backup (saved next to the database file)
vynilino backup create --db ./data/vynilino.db
# Create a signed backup (recommended for tamper detection)
vynilino backup create \
--db ./data/vynilino.db \
--output /backups/ \
--hmac-key "$VYNILINO_BACKUP_HMAC_KEY"
# Verify backup integrity and row count
vynilino backup verify \
--backup /backups/vynilino-20260325-120000.db \
--hmac-key "$VYNILINO_BACKUP_HMAC_KEY"Each backup produces three files:
<name>-<timestamp>.db— the compacted SQLite snapshot<name>-<timestamp>.db.count— expected row count sidecar<name>-<timestamp>.db.sig— HMAC-SHA256 signature (only when--hmac-keyis set)
restic backup \
/path/to/vynilino.db \
/path/to/media/rclone sync /path/to/data/ remote:vynilino-backup/Verify that the database schema is up to date without starting the server:
docker run --rm \
-v vynilino_data:/data \
-e VYNILINO_DB_PATH=/data/vynilino.db \
-e VYNILINO_TOKEN_KEY=<key> \
vynilino -check-migrationsVynilino ships a built-in hybrid web UI (desktop + mobile) served at GET /. It is embedded directly into the Go binary at build time — no separate CDN or SPA host required.
# Install frontend dependencies (first time only)
make ui-install
# Start the Vite dev server (hot-reload) proxied to your running Go backend
make ui-dev
# → opens http://localhost:5173
# Build production assets into web/dist/ (runs automatically before `make build`)
make ui-buildThe production JS bundle is < 30 kB gzipped using Alpine.js + Tailwind CSS 4. No React, Vue, or Angular runtime.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/me |
Bearer | Returns {"id","email"} for the current user or 401 |
POST |
/api/upload |
Bearer | Accepts multipart/form-data with a file field (≤ 5 MB JPEG/PNG/WebP); returns {"url":"..."} |
Static assets are compiled by Vite into web/dist/ and embedded in the Go binary via //go:embed all:dist in web/embed.go. The internal/adapter/ui package exposes:
SPAHandler()— serves static files with immutable cache headers; falls back toindex.htmlfor all non-API paths (SPA routing)MeHandler()—GET /api/meUploadHandler()—POST /api/upload
API routes (/graphql, /api/, /auth/, /media/, /export/, /import/) are registered before the SPA handler and always take precedence.
# Copy env template
cp .env.example .env
# Edit .env with your settings
VYNILINO_ENV=development \
VYNILINO_TOKEN_KEY=$(openssl rand -hex 32) \
go run ./cmd/vynilino serve
# Run tests
go test ./...
# Regenerate GraphQL code (after schema changes)
go run github.com/99designs/gqlgen generate
# Regenerate SQL code (after query changes)
sqlc generatecmd/vynilino/ # binary entrypoint and CLI commands
internal/
adapter/
discogs/ # Discogs API client
filestore/ # local cover-art storage
graphql/ # GraphQL server, middleware, resolvers
storage/sqlite/ # SQLite repositories (sqlc-generated)
ui/ # embedded SPA + REST helpers
app/ # application services (auth, records, OIDC, …)
config/ # environment-variable configuration
ctxutil/ # request-scoped context helpers
domain/ # core domain types (User, Record, Token, …)
openspec/ # OpenSpec specs and change archive
ui/ # Vite + Alpine.js frontend source
web/ # embedded assets (go:embed target)
Contributions are welcome! Please follow these steps:
- Open an issue or discussion before starting significant work, so we can align on design.
- Fork the repository and create a feature branch from
main. - Write tests for any new behaviour. Run
go test ./...before submitting. - Follow the OpenSpec workflow for non-trivial features: add a proposal under
openspec/changes/and link it in your PR description. This keeps the decision log complete. - Submit a pull request against
mainwith a clear description of what changed and why.
By contributing you agree that your contributions will be licensed under the Apache License 2.0.
Copyright 2026 Thibault NORMAND
Licensed under the Apache License, Version 2.0. See LICENSE for the full text.