Lynx is an open-source, self-hosted link page manager: public profile page, admin panel, SQLite storage, theme editor, analytics, and secure admin access in one small app.
- 🎬 Demo
- ✨ Features
- 🔐 Security
- 🚀 Quick Start
- ⚙️ Configuration
- 🐳 Docker
- ☁️ Railway
- 📝 Changelog
- 📄 License
- 🌐 Public page: https://lynx-demo.paoloronco.it
- 🛠 Admin panel: https://lynx-demo.paoloronco.it/admin
- 👤 Username:
admin - 🔑 Password:
ChangeMe123!
- 🎛 Admin panel: edit profile, links, theme, password, and reset options from one dashboard.
- 👤 Public profile: name, bio, avatar, social links, page title, meta description, favicon, and footer text.
- 🔗 Flexible cards: classic links, text cards, bulleted/grouped content, separators, icons, emojis, and images.
- 🎨 Theme control: colors, gradients, fonts, spacing, radius, blur, glow, button styles, link styles, and custom CSS.
- 👁 Live preview: check the public page while editing.
- 📊 Analytics: click tracking with an admin chart.
- 🗓 Scheduling: show or hide links by date range.
- 🙈 Visibility toggles: hide links without deleting them.
- 📱 Mobile-friendly editing: responsive UI and touch drag-and-drop ordering.
- 📦 Import/export: backup and restore links and themes as JSON.
- 🗄 Standalone storage: SQLite by default, no Firebase or Supabase required.
- 🔒 Optional HTTPS: enable a self-signed HTTPS listener with
ENABLE_HTTPS=true.
- Passwords are hashed with
bcryptjsusing 12 salt rounds. - Sessions use signed JWTs with a 12-hour expiry.
- The frontend stores JWTs encrypted in
localStoragewith AES-GCM. - SQLite access uses parameterized queries.
- API, auth, login, reset, and SPA routes are rate-limited.
- Docker startup requires
JWT_SECRETto avoid unstable sessions after restart. - Optional
RESET_TOKENsupports token-protected recovery if you are locked out.
Pick the path that matches what you want to do.
⚡ Run Lynx locally
Use this if you want to try Lynx on your machine with a production-style flow: the React app is built first, then the Express server serves both the frontend and the API.
Requirements
- Node.js
^20.19.0or>=22.12.0 - npm
- Git
1. Clone the repository
git clone https://github.com/paoloronco/Lynx.git
cd Lynx/LYNX2. Install dependencies
npm ci
npm run install:server3. Build and start
npm run startnpm run start runs the frontend build and then starts the backend server.
Open:
- 🌐 Public page: http://localhost:3001
- 🛠 Admin panel: http://localhost:3001/admin
- ❤️ Health check: http://localhost:3001/health
On the first admin visit, Lynx asks you to create the admin password. The username is always admin.
Local data is stored in LYNX/server/lynx.db unless you set DATA_DIR.
🧑💻 Development mode
Use this when you are editing the code and want frontend hot reload.
Development mode uses two running processes:
- Backend/API: Express server on http://localhost:3001
- Frontend: Vite dev server on http://localhost:8080
The Vite server gives you hot reload for React and proxies /api requests to the Express server.
1. Install dependencies once
cd Lynx/LYNX
npm ci
npm run install:server2. Start the backend in the first terminal
cd Lynx/LYNX
npm run server:dev3. Start the frontend in the second terminal
cd Lynx/LYNX
npm run devKeep both terminals open while developing.
Open:
- 🌐 Frontend: http://localhost:8080
- 🛠 Admin panel: http://localhost:8080/admin
- ❤️ API health check: http://localhost:3001/health
Stop either process with Ctrl+C.
🐳 Quick Docker run
Use this if you want the fastest container-based setup.
From the repository root:
docker compose up -dThe included docker-compose.yml uses:
- image:
paueron/lynx:latest - port:
8080 - volume:
./lynx-data:/app/data
Before exposing the app, replace the sample JWT_SECRET in docker-compose.yml.
Open:
- 🌐 Public page: http://localhost:8080
- 🛠 Admin panel: http://localhost:8080/admin
Environment variables
| Variable | Default | Notes |
|---|---|---|
JWT_SECRET |
random outside Docker | Required in Docker. Use a long random value in production. |
NODE_ENV |
unset | Set to production for production deployments. |
PORT |
3001 local, 8080 Docker |
HTTP server port. |
DATA_DIR |
LYNX/server local, /app/data Docker |
Stores lynx.db and persistent data. |
ENABLE_HTTPS |
false |
Set to true or 1 for self-signed HTTPS. |
SSL_PORT |
8443 |
HTTPS port. |
FRONTEND_URL |
same-origin mode | Optional dev CORS/CSP origin, e.g. http://localhost:8080. |
BASE_PATH |
unset | Optional additional mount path, e.g. /lynx. When set, the same instance works at both / and /lynx. |
PUBLIC_BASE_PATH |
unset | Backward-compatible alias for BASE_PATH. |
VITE_BASE_PATH |
unset | Optional dev-server equivalent of BASE_PATH when using npm run dev; production uses runtime BASE_PATH. |
PUBLIC_SITE_URL |
request host | Optional canonical public URL, e.g. https://links.example.com. Set this behind proxies or platforms where request host headers are not stable. |
PUBLIC_SITE_NAME |
Lynx |
Site name used in generated Open Graph, Twitter Card, and Schema.org metadata. |
SEO_INDEXING |
true |
Set to false, 0, no, or off to emit noindex metadata and a blocking robots.txt for private/staging deployments. |
RESET_TOKEN |
unset | Enables token-protected reset endpoints. Use at least 32 characters. |
VITE_ENABLE_USERCENTRICS_PRIVACY_PAGE |
true |
Build-time flag for the optional public /privacy Usercentrics embed. Set to false to disable. |
VITE_USERCENTRICS_PRIVACY_POLICY_ID |
fd1ffcdf-b560-4ea0-ba72-da943d39d953 |
Build-time Usercentrics privacy policy ID used by /privacy. |
VITE_USERCENTRICS_PRIVACY_POLICY_LANGUAGE |
en |
Build-time language passed to the Usercentrics privacy policy script. |
VITE_DEFAULT_PRIVACY_POLICY_URL |
/privacy |
Build-time public Privacy Policy URL. Set to an empty string to rely only on Admin > Profile > Legal links. |
💾 Data persistence
- Local: data is stored in
LYNX/server/lynx.dbunlessDATA_DIRis set. - Docker: mount
/app/dataso the database and uploads survive container updates.
Docker Compose
From the repository root:
docker compose up -dThe included docker-compose.yml uses:
- image:
paueron/lynx:latest - port:
8080 - volume:
./lynx-data:/app/data
Before exposing the app, replace the sample JWT_SECRET in docker-compose.yml.
Docker CLI
docker pull paueron/lynx:latest
docker run -d --name lynx \
-p 8080:8080 \
-e NODE_ENV=production \
-e PORT=8080 \
-e JWT_SECRET="replace-with-a-long-random-secret" \
-v lynx_data:/app/data \
paueron/lynx:latestOpen:
- 🌐 Public page: http://localhost:8080
- 🛠 Admin panel: http://localhost:8080/admin
Optional HTTPS
docker run -d --name lynx \
-p 8080:8080 \
-p 8443:8443 \
-e NODE_ENV=production \
-e PORT=8080 \
-e JWT_SECRET="replace-with-a-long-random-secret" \
-e ENABLE_HTTPS=true \
-v lynx_data:/app/data \
paueron/lynx:latestThen open https://localhost:8443. The browser will warn because the certificate is self-signed.
Deploy on Railway
Railway can deploy the repository using the root Dockerfile.
- Create a new Railway project from the GitHub repository.
- Let Railway use the Dockerfile in the repository root.
- Add:
NODE_ENV=production
PORT=8080
JWT_SECRET=replace-with-a-long-random-secret- Deploy the service.
- Add a public domain from the Railway service settings.
Railway already provides HTTPS at the edge, so ENABLE_HTTPS is normally not needed there.
🌍 Other hosting options
Lynx can run anywhere that supports a Node app or a Docker container: Render, Fly.io, DigitalOcean App Platform, Google Cloud Run, Heroku Container Runtime, Azure App Service, AWS Elastic Beanstalk, Koyeb, Northflank, CapRover, Dokku, and Coolify.
For any container deployment, persist /app/data and set JWT_SECRET.
v4.1.8
- Allows the current Usercentrics embed domains in the production CSP.
- Keeps embedded legal policy scripts executable and ordered when rendered on
/privacyand/cookies. - Adds a regression test for legal policy provider CSP sources.
v4.1.7
- Executes custom external CMP scripts reliably, including pasted iubenda widget snippets.
- Publishes Docker images only from release tags to avoid duplicate Docker builds on
mainandv*. - Keeps
latest, semantic version, and short SHA Docker tags on release builds.
v4.1.6
- Adds a server-side demo preset for Privacy Policy, Cookie Policy, and external CMP.
- Serves the demo legal pages from the requested iubenda embeds when
DEMO_MODE=true. - Keeps public footer links on
/privacyand/cookiesin demo mode. - Ensures iubenda embedded policy scripts load reliably in SPA-rendered legal pages.
v4.1.5
- Stops demo-mode write protection from being shown as an expired admin session.
- Ensures embedded legal policy scripts execute on
/privacyand/cookies. - Infers hosted legal pages from existing
/privacyand/cookiesprofile URLs for upgraded installs.
v4.1.4
- Separates legal pages from consent management in the Privacy tab.
- Adds provider-agnostic legal policy sources: external link, hosted text, and embedded code.
- Replaces provider-specific CMP fields with a single external script flow.
- Ensures
/privacyand/cookiesalways render the latest selected source without stale provider fallback. - Keeps form edits on screen when the admin session expires during save.
v4.1.3
- Ensures Google Consent Mode v2 defaults are set before analytics scripts load.
- Defaults ad storage, analytics storage, user data, and personalization to denied when consent is enabled.
- Avoids duplicate default consent blocks when an advanced provider already supplies one.
v4.1.2
- Redesigned the Privacy tab legal policy setup with a guided, non-technical flow.
- Added a footer visibility toggle, configured/missing status, and preview links for
/privacyand/cookies. - Added a public
/cookiesplaceholder page.
v4.1.1
- Moved Privacy Policy and Cookie Policy editing from Profile to Privacy.
- Kept the existing profile-backed fields as the single persistence source.
- Forced the public
/privacypage to render in a readable light layout.
v4.1.0
- Made Admin > Profile > Legal links the single editable source for Privacy Policy and Cookie Policy URLs.
- Shows configured legal links in the public footer and hides them cleanly when empty.
- Makes the Privacy & Cookies tab read-only for policy URLs, with an Edit in Profile shortcut.
- Ensures the native cookie banner derives policy URLs from the profile instead of storing duplicate consent-config URLs.
v4.0.0
- Redesigned the Admin panel with a clearer dashboard layout, status metrics, sticky centered navigation, and a lighter operational workspace.
- Improved the Links editor with a clearer toolbar, content creation cards, save state visibility, and a more helpful empty state.
- Added animated profile checklist guidance and save confirmation feedback for theme changes.
- Kept the public page preview isolated from the Admin styling so it continues to reflect the saved public theme.
- Added a single public-page payload endpoint to load profile, links, and theme together and avoid flashes of default content.
- Preserved compatibility with existing SQLite databases through additive migrations only.
v3.8.0
- Added Google Analytics 4 integration in the Admin panel (new Integrations tab).
- The GA4 Measurement ID (
G-XXXXXXXXXX) is stored in the database and injected as agtag.jsscript on the public page only — the admin panel is never tracked. - Content Security Policy updated to allow
googletagmanager.comandgoogle-analytics.comscript and connect sources. - Measurement ID is validated client-side before saving (format
G-XXXXXXXXXX).
v3.7.0
- Fixed production blank page behavior caused by CORS/CSP headers blocking API calls in production containers.
- Fixed stale frontend assets in Docker builds by cleaning
distbefore building. - Fixed legacy database migration handling.
- Fixed missing
fsimport indatabase.js.
- Improved production CORS handling for same-origin and reverse-proxy deployments.
- Refined Content Security Policy settings.
- Added static asset serving logs for deployment troubleshooting.
- Improved database migration validation and error handling.
v3.6.0
- Added live preview inside the admin panel.
- Added a View Public Page action from the admin header.
- Added link visibility toggles.
- Added mobile drag-and-drop ordering.
- Removed sensitive authentication logs.
- Removed unused Supabase and Firebase code.
- Fixed duplicate database migration logic.
- Removed debug logging from
PublicLinkCard.
v3.5.1
- Updated vulnerable frontend, backend, and Docker dependencies.
- Resolved Dependabot alerts and Docker image CVEs reported at the time of release.
- Optimized Docker build time by avoiding source builds where precompiled binaries are available.
v3.5.0
- Added editable profile fields with line-break support in the bio.
- Added social link controls and profile picture display controls.
- Added text cards and bulleted lists.
- Added JSON import/export for links and themes.
- Added theme controls for page styling, typography, title, meta description, and footer text.
- Added Docker startup validation for
JWT_SECRET. - Added optional self-signed HTTPS support with
ENABLE_HTTPS=true.
Lynx generates SEO metadata from each deployment's saved profile, so forks and self-hosted instances do not need to edit source files for basic discoverability.
Set BASE_PATH when the same Lynx instance should also be available below a subpath:
BASE_PATH=/lynxWith that setting, Lynx serves the same app from both / and /lynx:
- Public page:
/and/lynx - Admin:
/adminand/lynx/admin - API:
/api/...and/lynx/api/... - Static assets/uploads:
/assets/...,/uploads/...,/lynx/assets/..., and/lynx/uploads/...
The server injects the configured base path into the frontend at runtime, so production Docker/Node deployments only need BASE_PATH. When running the Vite dev server directly, also set VITE_BASE_PATH=/lynx so the dev frontend can choose the same React Router basename.
- Serves profile-specific
<title>, meta description, canonical URL, robots meta, Open Graph tags, Twitter Cards, and Schema.org JSON-LD from the Express server. - Generates
/robots.txtdynamically and points crawlers to/sitemap.xml. - Generates
/sitemap.xmlwith the canonical home page and local legal pages when/privacyor/cookiesare configured as profile policy URLs. - Marks
/admin,/api/*,/health, and unknown SPA paths asnoindexto avoid duplicate or private pages in search results. - Adds a no-JavaScript fallback for the public profile links so crawlers can discover the main outbound links before the React app hydrates.
PUBLIC_SITE_URL=https://links.example.com
PUBLIC_SITE_NAME="Your Name or Brand"
SEO_INDEXING=trueUse the Admin panel to set the profile name, bio, page title, meta description, avatar, social links, and legal policy URLs. Lynx reuses those values for search snippets, social previews, and structured data.
For staging or private forks, disable indexing without changing code:
SEO_INDEXING=falseThis makes robots.txt disallow crawling and makes served SPA pages emit noindex, nofollow, noarchive.
If PUBLIC_SITE_URL is not set, Lynx derives canonical URLs from the incoming request host and protocol, including common reverse proxy headers. Set PUBLIC_SITE_URL when the app is behind a proxy, tunnel, CDN, or platform that may send internal hostnames.
- Public routes must have one canonical URL and should not create duplicate indexable paths.
- New private or admin routes must send
X-Robots-Tag: noindex, nofollow, noarchive. - New public pages should be added to the sitemap only when they have durable public content.
- Public links should be real
<a href="...">elements, not click-only JavaScript handlers. - Images that are not above the fold should use lazy loading and useful alt text.
- Do not block
/assets, CSS, JavaScript, or uploaded public images inrobots.txt. - Keep metadata configurable through profile data or environment variables, not hardcoded to the upstream demo domain.
- If the app becomes multilingual, add language-specific routes and reciprocal
hreflanglinks before enabling localized indexing.
MIT License. See LICENSE.txt.

