Free, open-source Linear feedback infrastructure: customer request forms, public issue views, public roadmaps, custom domains and branded portals backed by your own Linear workspace.
linear.gratis is built for teams that already run product work in Linear but do not want to pay for a separate feedback portal just to collect requests, share progress, or publish a roadmap.
- Features
- Architecture
- Getting Started
- Environment Variables
- Database Migrations
- Scripts
- Deployment
- Project Structure
- Security
- Contributing
- Self-Hosting Notes
- License
- Customer request forms that create Linear customers and issues.
- Public Linear views for sharing filtered issues with customers or stakeholders.
- Public roadmaps with voting, comments, password protection and expiry support.
- Custom domains for forms, views and roadmaps.
- Branded public pages with logos, colours, favicons and footer settings.
- Organisation-scoped access control backed by Supabase RLS.
- Organisation-scoped Linear connections instead of user-scoped token reads.
- Encrypted Linear API token storage with lazy legacy-token rotation.
- Schema-level publishable-resource model with cross-type slug uniqueness.
- SQL migration, rollback and invariant-test coverage for the core tenancy model.
| Layer | Technology |
|---|---|
| App | Next.js 15 App Router, React 19, TypeScript |
| UI | Tailwind CSS, Radix UI primitives, Lucide icons, Framer Motion |
| Auth and database | Supabase Auth, Postgres and RLS |
| Linear integration | Linear GraphQL API and @linear/sdk |
| Runtime | OpenNext for Cloudflare Workers |
| Deployment | Wrangler and Cloudflare Workers static assets |
The app is intentionally server-heavy around Linear access. Linear tokens stay on the server, are encrypted at rest, and are resolved through organisation-owned connection rows before API calls are made.
- Node.js 20 or newer
- npm
- Docker, if you want to run Supabase locally
- Supabase CLI, for local database resets and migration work
- Wrangler, installed through project dependencies
- A Linear workspace and Linear API token
- A Cloudflare account, if deploying the Worker or using custom-domain checks
git clone https://github.com/curiousgeorgios/linear-gratis.git
cd linear-gratis
npm install
cp .env.example .env.localGenerate an encryption key for stored Linear tokens:
openssl rand -base64 32Fill out .env.local, then start the app:
npm run devOpen http://localhost:3000.
The local template lives in .env.example.
| Variable | Required | Purpose |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Yes | Supabase project URL used by browser and server clients. |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Yes | Supabase anon key for client-side authenticated access. |
SUPABASE_SERVICE_ROLE_KEY |
Yes | Server-only key for API routes and trusted migration-style operations. |
ENCRYPTION_KEY |
Yes | Base64 32-byte key used to encrypt Linear API tokens. |
IP_HASH_SALT |
Yes in production | Stable random salt for pseudonymous public-roadmap IP metadata. Voting and comments remain available if it is absent. |
CLOUDFLARE_API_TOKEN |
For custom domains | Cloudflare API token for DNS and hostname verification checks. |
CLOUDFLARE_ACCOUNT_ID |
For custom domains | Cloudflare account id used with the API token. |
CLOUDFLARE_ZONE_ID |
For custom domains | Cloudflare zone ID used to manage custom hostnames. |
FEEDBACK_WEBHOOK_SECRET |
For signed feedback | Shared HMAC secret for inbound public-view feedback webhooks. |
NEXT_PUBLIC_APP_DOMAIN |
Recommended | Canonical app domain used for custom-domain CNAME instructions. |
SUPABASE_DB_HOST |
Deployments | Production database host used only by the migration gate. |
SUPABASE_DB_PORT |
Deployments | Production database port, normally 5432. |
SUPABASE_DB_NAME |
Deployments | Production database name, normally postgres. |
SUPABASE_DB_USER |
Deployments | Project-scoped production database user. |
SUPABASE_DB_PASSWORD |
Deployments | Production database password. Never expose this to previews or the Worker runtime. |
Never expose SUPABASE_SERVICE_ROLE_KEY, ENCRYPTION_KEY, or Cloudflare tokens
to the browser. Infisical prod is the source of truth for production secrets;
the corresponding Worker secrets are deployment copies. Cloudflare cannot reveal
an existing secret value, so migrate a Worker-only secret by recovering it from
its original secure source or by coordinating a rotation with its consumers.
Forward migrations live in supabase/migrations. Manual rollbacks live in supabase/rollbacks.
Production releases use Supabase's standard
supabase_migrations.schema_migrations ledger. The release runner takes a
PostgreSQL advisory lock, performs a dry-run preflight, applies pending
migrations, and verifies the ledger again before Wrangler can deploy the Worker.
It intentionally does not use --include-all: missing or out-of-order history
stops a release and requires an explicit audited repair.
Important: do not move rollback SQL into supabase/migrations. Supabase applies
every SQL file in that directory as a forward migration.
For a fresh local database:
supabase start
supabase db resetFor a linked Supabase project:
supabase db push --linkedRecent schema work is documented in:
The SQL tests exercise the core migration invariants introduced by migrations
019-023. The runner refuses non-local database hosts and every SQL file ends in
ROLLBACK:
TEST_DATABASE_URL="postgresql://postgres:[email protected]:54322/postgres" \
bun run test:dbThe default suite is deterministic and offline. It covers business rules, cryptography, redaction, Linear pagination with mocked network responses, data and schema invariants, UI render contracts, and security controls across every API route. Fuzz and metamorphic cases use fixed seeds so failures reproduce.
bun test
bun run test:coverage
bun run test:stress
bun run test:smoke
bun run test:alltest:coverage enforces an explicit manifest of production modules at 80%
minimum per-file line/function coverage and 95%/90% overall loaded-source
line/function coverage. test:all adds type checking, lint, a production build,
an OpenNext Cloudflare Worker bundle, and headless smoke checks of the generated
marketing and authentication routes.
Database tests remain separate because they require a disposable local Supabase
instance. The GitHub Actions test workflow runs both layers for every pull
request and every push to main.
| Command | Description |
|---|---|
bun test |
Run deterministic unit, property, contract, and render tests. |
bun run test:coverage |
Run tests and enforce production-module coverage thresholds. |
bun run test:stress |
Repeat every test file 20 times to expose order or lifecycle flakiness. |
bun run test:smoke |
Build and visit representative production pages in headless Chromium. |
bun run test:db |
Run rollback-only SQL invariants against TEST_DATABASE_URL on localhost. |
bun run test:all |
Run coverage, types, lint, build, and browser smoke gates. |
bun run db:migrate:production |
Lock, preflight, apply, and verify production migrations using Infisical-injected database settings. |
bun run release:build |
Run the complete production release gate. |
bun run release:deploy |
Apply migrations and deploy the already-built Worker, in that order. |
npm run dev |
Start the Next.js development server. |
npm run build |
Build the Next.js app. |
npm run build:worker |
Build the Cloudflare Worker bundle through OpenNext. |
npm run preview |
Build the Worker and run it locally with Wrangler. |
npm run deploy |
Build and deploy the Worker with Wrangler. |
npm run cf-typegen |
Generate Cloudflare environment types. |
npm run generate-og |
Generate Open Graph assets. |
This project deploys as a Cloudflare Worker via OpenNext. The Worker config is in wrangler.jsonc.
bun run deployManage or rotate the value in Infisical first, then mirror it to the Worker
without committing it. The deploy script builds, tests, migrates, and deploys
with Infisical prod injected. If migration preflight or verification fails,
Wrangler is never invoked.
Cloudflare Workers Builds is connected to the GitHub main branch for automatic
production releases. Its production build command is
bun run release:cloudflare:build and its deploy command is
bun run release:cloudflare:deploy; both fetch their environment from Infisical
before invoking the underlying release stages. Cloudflare's build image runs
coverage, types, lint, and the Worker build; its deploy stage then waits for the
same commit's required GitHub application/browser and database checks. A failed,
cancelled, missing, or timed-out GitHub check prevents migrations and deployment.
The only Cloudflare build secret is a read-only, production-scoped
INFISICAL_TOKEN; application and database secrets remain in Infisical. Preview
builds are disabled so pull requests and forks cannot receive production
credentials or run production migrations.
The production trigger pins BUN_VERSION=1.2.21 to match the repository and
GitHub Actions coverage runtime.
The self-hosted Infisical API domain is public configuration and is pinned in
.infisical.json; tokens and secret values are never stored there.
infisical secrets set --env=prod SECRET_NAME=@/path/to/secure-value
npx wrangler secret put SECRET_NAMENEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are public
configuration values and may live in Wrangler vars; they are also available in
the Infisical project so release builds have one consistent configuration source.
src/
app/ Next.js routes and API handlers
components/ Shared UI and product components
contexts/ Client-side auth state
data/ Static marketing and SEO content
lib/ Supabase, Linear, auth, crypto and domain helpers
supabase/
migrations/ Forward-only database migrations
rollbacks/ Manual rollback scripts, ignored by Supabase migration flow
tests/ SQL invariant tests
contracts/ Future contract-phase migration drafts
docs/
adr/ Architecture decision records and runbooks
tests/ Unit, property, contract and render suites
- Linear API tokens are encrypted at rest and decrypted only in server-side code.
- Public password access cookies are signed, scoped and tied to the stored hash.
- Public routes enforce active, expiry and password gates server-side.
- Supabase RLS scopes authenticated resources through organisation membership.
- Service-role code paths re-check organisation membership before mutating data.
Please do not open public issues for suspected vulnerabilities. Email [email protected] with enough detail to reproduce the problem.
Contributions are welcome. For non-trivial changes, open an issue or discussion first so the schema, security and deployment implications can be worked through.
- Fork the repository.
- Create a branch from
main. - Make the smallest coherent change.
- Run the relevant verification commands.
- Open a pull request with the behavior change, migration impact and test coverage called out clearly.
Before submitting database changes, check:
- Forward migrations are in
supabase/migrations. - Manual rollbacks are in
supabase/rollbacks. - New invariants have SQL coverage in
supabase/tests. - Public-surface changes do not expose secrets, hashes, tokens or private Linear metadata.
By default, every user belongs to a personal organisation and resources are scoped to organisation members. That is the correct default for a public SaaS deployment.
For a private agency or internal fork where every account on the instance is a
trusted colleague, you may want one shared organisation. Run the recipe below
only after migration 015_organisations.sql has applied. This is fork-specific
configuration, not part of the standard migration suite.
BEGIN;
-- 1. Create one shared organisation owned by the oldest user on the instance.
INSERT INTO organisations (id, name, slug, created_by)
VALUES (
gen_random_uuid(),
'Team',
'team',
(SELECT id FROM profiles ORDER BY created_at LIMIT 1)
);
-- 2. Add every user as a member.
INSERT INTO organisation_members (organisation_id, user_id, role)
SELECT
(SELECT id FROM organisations WHERE slug = 'team'),
id,
CASE
WHEN id = (SELECT id FROM profiles ORDER BY created_at LIMIT 1)
THEN 'owner'::org_role
ELSE 'member'::org_role
END
FROM profiles;
-- 3. Point every existing resource at the shared org.
UPDATE public_views SET organisation_id = (SELECT id FROM organisations WHERE slug = 'team');
UPDATE customer_request_forms SET organisation_id = (SELECT id FROM organisations WHERE slug = 'team');
UPDATE branding_settings SET organisation_id = (SELECT id FROM organisations WHERE slug = 'team');
UPDATE custom_domains SET organisation_id = (SELECT id FROM organisations WHERE slug = 'team');
UPDATE roadmaps SET organisation_id = (SELECT id FROM organisations WHERE slug = 'team');
-- 4. Drop now-empty personal organisations.
DELETE FROM organisations o
WHERE NOT EXISTS (SELECT 1 FROM public_views WHERE organisation_id = o.id)
AND NOT EXISTS (SELECT 1 FROM customer_request_forms WHERE organisation_id = o.id)
AND NOT EXISTS (SELECT 1 FROM branding_settings WHERE organisation_id = o.id)
AND NOT EXISTS (SELECT 1 FROM custom_domains WHERE organisation_id = o.id)
AND NOT EXISTS (SELECT 1 FROM roadmaps WHERE organisation_id = o.id);
COMMIT;New signups still get personal organisations through the handle_new_user
trigger. Re-run the membership/resource steps periodically if your fork keeps
the shared-org model, or customise the trigger for your own deployment.
MIT. See LICENSE.