A tiny, fast, self-hosted URL shortener built for Docker Compose. zer0 keeps the redirect path lean, protects public link creation with CAPTCHA, stores links in Redis, and includes a browser admin dashboard for stats and cleanup.
- π Fast redirect hot path:
GET /:codevalidates the slug, checks the in-process hot cache, falls back to Redis on cache miss, records stats, and returns a302 Location. - π§ In-process hot cache: repeated redirects avoid Redis reads while still recording click stats.
- π§© Custom slug generator: users can provide a safe custom slug or let zer0 generate a friendly one from the homepage.
- π‘οΈ Cloudflare Turnstile on creation and admin access: redirects never pay the CAPTCHA cost; the admin dashboard login always requires CAPTCHA, and admin-only creation places CAPTCHA with the creator unlock gate instead of the link form.
- π Admin-only creation mode: set
ADMIN_ONLY_MODE=trueso only visitors with the admin token can create short URLs while redirects stay public. - ποΈ Redis persistence: Docker Compose uses Redis 7 with AOF enabled.
- β³ User-selected validity: pre-fill link validity from configuration, then let creators choose finite or indefinite validity per link.
- π¨ Modern responsive UI: the public creator and admin workspace share a tidy design system with accessible controls, focused dialogs, and compact mobile layouts.
- π Theme selection: choose System, Light, or Dark across the public creator, admin dashboard, and invalid-link page.
- πͺ Branded invalid-link page: expired, removed, missing, or malformed public short URLs render a helpful HTML page instead of plain
Not foundtext. - π‘ Self-hosting prompt: the homepage footer invites visitors to self-host their own zer0 and links to the source code at https://github.com/neoemit/zer0.
- π Admin stats: total clicks plus country buckets from local GeoIP lookup.
- π§ Admin dashboard: token-based login, local token persistence, search, pagination, country names, refresh, logout, dialog-based link editing, guided import/export, deletion confirmation, and accessible status updates.
- π§Ή Cleanup controls: delete one link or all links; deletion removes metadata, stats, and hot-cache entries so slugs can be reused with fresh counters.
- Node.js 24 + Fastify 5: small service with low-overhead HTTP routing.
- Redis 7: persistent key/value backing store with optional per-link TTLs.
- geoip-lite: local country-level GeoIP lookup; unknown/private IPs are grouped as
ZZ. - Cloudflare Turnstile: CAPTCHA protection for public URL creation, admin dashboard access, and admin-only creator unlocks.
- Docker Compose: one app container plus Redis.
cp .env.example .env
# edit PUBLIC_BASE_URL, TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY, and ADMIN_TOKEN
docker compose up -d --buildThen open http://localhost:3000 or your configured PUBLIC_BASE_URL.
Useful endpoints:
- π Homepage:
/ - π¨ Favicon:
/favicon.svg - π©Ί Health check:
/healthz - π Admin dashboard:
/adminwhenADMIN_TOKENis configured
PUBLIC_BASE_URL: public origin used when returning short URLs.PORT: host/container app port. Default3000.TRUST_PROXY: settruebehind a trusted reverse proxy so IP-based stats use forwarded client IPs.CAPTCHA_PROVIDER:turnstileornone. Defaults toturnstilewhen a Turnstile secret exists, otherwisenone.TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY: Cloudflare Turnstile keys for public creation CAPTCHA, admin dashboard access CAPTCHA, or admin-only creator unlock CAPTCHA.CODE_LENGTH: generated short-code length. Default7gives about 3.5 trillion possible base62 codes.RETENTION_DAYS: default validity in days used to pre-fill the creation form and default API requests.0or unset means indefinite by default; users can still choose a different value per link.ADMIN_TOKEN: enables admin-only stats/list/delete APIs and the/admindashboard. Leave unset to disable the admin API.ADMIN_ONLY_MODE: set totrueto require the admin token before visitors can access the homepage creator or callPOST /api/shorten. Redirects stay public.HOT_CACHE_MAX_ENTRIES: max in-process redirect cache entries.HOT_CACHE_TTL_MS: hot cache TTL in milliseconds.REDIRECT_CACHE_CONTROL:Cache-Controlheader on redirects. Defaultpublic, max-age=300helps browsers/CDNs cache redirects.CREATE_RATE_LIMIT_MAX/CREATE_RATE_LIMIT_WINDOW: rate limit for the creation endpoint only.MAXMIND_LICENSE_KEY: free GeoLite2 key that keeps the country database current. See GeoIP database updates.
For local development without CAPTCHA:
CAPTCHA_PROVIDER=none
β οΈ Do not useCAPTCHA_PROVIDER=noneon a public instance.
curl -X POST http://localhost:3000/api/shorten \
-H 'content-type: application/json' \
-d '{"url":"https://example.com","slug":"optional-custom-slug","validityDays":30,"captchaToken":"TURNSTILE_TOKEN"}'When ADMIN_ONLY_MODE=true, the homepage asks for the admin token and CAPTCHA together before showing the creator. API clients should verify that pair first:
curl -X POST http://localhost:3000/api/creation-auth \
-H 'content-type: application/json' \
-d '{"adminToken":"'$ADMIN_TOKEN'","captchaToken":"TURNSTILE_TOKEN"}'Then create links with the admin token header. The creation request itself does not include a CAPTCHA token in admin-only mode because the CAPTCHA belongs to the unlock step:
curl -X POST http://localhost:3000/api/shorten \
-H 'content-type: application/json' \
-H 'X-Admin-Token: $ADMIN_TOKEN' \
-d '{"url":"https://example.com","slug":"optional-custom-slug","validityDays":30}'Only visitors with the admin token can create short URLs in admin-only mode. Redirects stay public, so every short URL that an admin creates can still be used by anyone.
Response:
{
"code": "abc123",
"shortUrl": "https://z.example.com/abc123",
"targetUrl": "https://example.com/",
"expiresInDays": 30
}validityDays is optional. When omitted, zer0 uses the configured RETENTION_DAYS default. 0 means the link is valid indefinitely. expiresInDays is null for indefinite links, otherwise it is the selected positive validity value.
curl -i http://localhost:3000/abc123Returns 302 Location: <target-url> when the slug exists. If the short URL is expired, removed, malformed, or missing, zer0 returns a branded invalid-link page explaining that the URL is no longer valid and asking the visitor to request a fresh link from the person who shared it.
The public homepage is intentionally lightweight and friendly:
- a URL creation form with optional custom slug input, validity-days input, and a custom slug generator;
- an admin-token gate with CAPTCHA for the creation form when
ADMIN_ONLY_MODE=true; - a stable result panel with copy and open-link actions;
- a System / Light / Dark theme selector persisted in browser
localStorage; - a stylised zero favicon served from
/favicon.svg; - a "Made with β€οΈ in Cape Town" footer;
- a self-hosting note linking to the source code: https://github.com/neoemit/zer0.
Self-host your own zer0 by forking or cloning the repository, configuring .env, and running Docker Compose from the quick-start commands above.
Admin routes require ADMIN_TOKEN. The browser dashboard at /admin always asks for the admin token and CAPTCHA together when CAPTCHA is enabled, independent of ADMIN_ONLY_MODE. API clients can validate that pair before loading dashboard data:
curl -X POST http://localhost:3000/api/admin/auth \
-H 'content-type: application/json' \
-d '{"adminToken":"'$ADMIN_TOKEN'","captchaToken":"TURNSTILE_TOKEN"}'After that access check, admin API calls use the admin token header:
-H 'X-Admin-Token: $ADMIN_TOKEN'The admin API also accepts a bearer Authorization header.
If ADMIN_TOKEN is unset, admin APIs return 503 and stay disabled.
curl -H 'X-Admin-Token: $ADMIN_TOKEN' \
http://localhost:3000/api/stats/abc123{
"code": "abc123",
"totalClicks": 42,
"countries": {
"IT": 20,
"US": 12,
"ZZ": 10
}
}curl -H 'X-Admin-Token: $ADMIN_TOKEN' \
http://localhost:3000/api/admin/links{
"links": [
{
"code": "abc123",
"shortUrl": "https://z.example.com/abc123",
"targetUrl": "https://example.com/",
"createdAt": "2026-06-08T12:00:00.000Z",
"validityDays": 30,
"expiresAt": "2026-07-08T12:00:00.000Z",
"expiresInDays": 30,
"totalClicks": 42,
"countries": {
"IT": 20,
"US": 12,
"ZZ": 10
}
}
]
}Admins can edit a link slug, destination URL, and validity. Saving a positive validity value resets expiry from the save time. Saving 0 makes the link valid indefinitely. Slug changes are non-destructive: if the requested slug already exists, zer0 returns 409 and leaves both links unchanged.
curl -X PATCH -H 'content-type: application/json' \
-H 'X-Admin-Token: $ADMIN_TOKEN' \
-d '{"code":"new-slug","targetUrl":"https://example.com/new","validityDays":0}' \
http://localhost:3000/api/admin/links/abc123For backward compatibility, a validity-only payload such as {"validityDays":30} still works.
Export downloads a zer0 JSON backup that can be imported later to restore links with metadata, validity, expiry, total click counts, and country stats.
curl -H 'X-Admin-Token: $ADMIN_TOKEN' \
http://localhost:3000/api/admin/exportThe export response has this shape:
{
"schemaVersion": 1,
"app": "zer0",
"exportedAt": "2026-06-13T12:00:00.000Z",
"links": [
{
"code": "abc123",
"targetUrl": "https://example.com/",
"createdAt": "2026-06-08T12:00:00.000Z",
"validityDays": 30,
"expiresAt": "2026-07-08T12:00:00.000Z",
"expiresInDays": 30,
"stats": {
"totalClicks": 42,
"countries": {
"IT": 20,
"US": 12,
"ZZ": 10
}
}
}
]
}Import is admin-only and non-destructive by default. Existing slugs are skipped, invalid rows are reported, and valid rows continue importing.
curl -X POST -H 'content-type: application/json' \
-H 'X-Admin-Token: $ADMIN_TOKEN' \
-d @zer0-export.json \
http://localhost:3000/api/admin/importFor API imports, send either a zer0 export wrapper:
{
"mode": "zer0",
"export": {
"app": "zer0",
"links": []
}
}Or custom normalized records:
{
"mode": "custom",
"records": [
{
"targetUrl": "https://example.com/",
"code": "optional-slug",
"validityDays": 0,
"totalClicks": 0,
"countriesJson": "{\"US\":2}"
}
]
}Custom CSV imports are available in the admin dashboard. Choose Custom CSV, upload a CSV file, map the required target URL column, and optionally map slug, timestamps, validity, expiry, total clicks, and country stats. Optional fields can also use a manual fallback value, for example validity 0 for every row. Missing optional fields use zer0 defaults: generated slug, current creation time, indefinite validity, and zero stats.
Common custom headers are detected automatically, including source as slug, target as destination URL, and hits as total clicks. Imported slugs are sanitized by stripping invalid characters, so /snippets/ becomes snippets. If total clicks are provided without country stats, zer0 assigns those clicks to ZZ / Unknown.
Deletion removes the URL record, metadata, stats, and in-process hot-cache entry. Once deleted, the slug is available again; if recreated, counters start from zero.
# Delete one record
curl -X DELETE -H 'X-Admin-Token: $ADMIN_TOKEN' \
http://localhost:3000/api/admin/links/abc123
# Delete all records
curl -X DELETE -H 'X-Admin-Token: $ADMIN_TOKEN' \
http://localhost:3000/api/admin/linksOpen /admin, paste ADMIN_TOKEN, and load the dashboard.
The dashboard:
- stores a successfully used token in browser
localStoragefor future visits; - hides the token form and shows links only after authentication succeeds;
- supports refresh, logout, substring search across all slug and destination records before pagination, a remembered row-count preference, side-panel slug/destination/validity edits, zer0 export downloads, and a guided import dialog;
- asks for confirmation in an accessible dialog before deleting a link;
- opens an accessible country breakdown with flags when a link's click total is selected;
- suppresses zero-valued country counters for unclicked links;
- clears the stored token on logout or failed
401authentication.
The frontend remains build-free. Shared CSS and JavaScript are served from /assets/*, and the System / Light / Dark theme preference is shared across all HTML pages.
npm install
npm test
npm run lint
npm startLocal app defaults:
CAPTCHA_PROVIDER=none npm startEvery pull request must include a ## Release notes section. Use - No user-facing changes. when a PR should not appear in release notes. GitHub Actions validates that section on PRs, and .github/release.yml configures GitHubβs generated release notes for tagged releases.
The project is currently versioned at 0.2.0; create future GitHub releases from version tags after merging release-worthy changes.
geoip-lite ships a GeoLite2 snapshot that is frozen when the npm package is published, while MaxMind reassigns address ranges twice a week. An instance running on the bundled copy therefore reports a slowly growing share of clicks as ZZ / Unknown, because newly allocated IPs are not in its database yet.
MAXMIND_LICENSE_KEY is optional. Set it and zer0 refreshes the database itself: once at startup, then every seven days, reloading the data in place without a restart. The update runs in a child process, so redirects are unaffected, and MaxMind's checksum is compared first, so an already-current database costs one small request. Leave it unset and nothing is downloaded β the bundled snapshot is used as-is, and everything else in zer0 works the same either way.
To enable it:
- Create a free MaxMind account at maxmind.com/en/geolite2/signup.
- Under My License Keys, generate a new license key (no credit card required).
- Set
MAXMIND_LICENSE_KEYto that key in.env(or your deployment's environment) and restart zer0.
Under Docker Compose the refreshed database lives in the geoip-data volume, seeded from the image on first run, so updates survive container restarts and image rebuilds.
- Keep Redis on the same Docker network/host as the app.
- Put a CDN or reverse proxy in front and keep
REDIRECT_CACHE_CONTROLenabled if links do not need instant retargeting. - Scale reads horizontally by running multiple
zer0containers against the same Redis; each container keeps its own hot cache. - Every redirect records click stats in Redis, even when the target URL comes from hot cache.
- The creation endpoint is intentionally heavier because it validates URLs, rate-limits, and verifies CAPTCHA. It is separate from the redirect hot path.
Direct runtime dependencies are permissively licensed:
@fastify/formbody: MIT@fastify/rate-limit: MITfastify: MITgeoip-lite: Apache-2.0ioredis: MITnanoid: MITundici: MIT
npm audit --omit=dev --audit-level=moderate currently reports zero known production vulnerabilities.
zer0 is open source under the MIT License.
MIT is a good fit here because zer0 is an infrastructure tool meant to be easy to self-host, fork, embed, and improve with minimal legal friction while remaining compatible with the current permissive dependency set.