TinyRoute is a portfolio-scale URL shortener built to demonstrate production-minded web engineering without turning a one-developer project into a platform. Visitors follow short links without an account. Signed-in users create and manage their own links, while the backend owns authentication, authorization, redirects, analytics, rate limits, and persistence.
Work in progress. Authentication, HTTPS link creation/custom aliases/expiry, public redirects, paginated owned links, and private short-link analytics are implemented. Analytics provides an overview, per-link reports, UTC date filters, daily trends, and six anonymous traffic breakdowns. Frontend/backend CI validate pull requests. Search and link-management mutations, production infrastructure, and deployment delivery remain future work. The repository is not deployed software. See analytics setup, including optional local geography and capture limitations.
The MVP includes:
- Email/password and Google sign-up; sign-in, sign-out, persisted sessions, password reset, and account deletion.
- Authenticated creation of HTTPS short links, custom aliases, expiry, and copyable results.
- Public redirects that fail closed for unknown, disabled, deleted, case-mismatched, and expired codes.
- Owner-only link list, search, destination editing, disable/re-enable, and permanent deletion.
- Click totals, date-range trends, and aggregated referrer, device, OS, browser, country, and city analytics, with a zoomable country map for each link.
- Creation, authentication, and redirect throttling.
The full scope, priority, and acceptance criteria live in Functional requirements. Performance, security, reliability, privacy, and delivery targets live in Non-functional requirements.
Browser (Next.js / Vercel)
│ credentialed HTTPS + CSRF header
▼
Caddy HTTPS ingress (Hostinger VPS, planned)
│ private HTTP; API and public redirect hosts
▼
Spring Boot API (Docker on the VPS, planned)
├── Controllers: HTTP translation only
├── Services: policy, transactions, ownership
├── JPA repositories: PostgreSQL system of record
└── Redis adapters: refresh sessions, JWT revocation, rate limits, redirect cache
│ │
▼ ▼
Supabase PostgreSQL Redis container
(remote JDBC over TLS) (private VPS network + persistent volume)
Next.js is an HTTP client only. It does not proxy ordinary API calls, handle the public short-code route, store tokens, or recreate backend policy. Spring Boot owns all security, ownership, validation, redirect, and rate-limit decisions.
The authoritative design is in Architecture. The records behind the locked frontend, backend, and production-delivery choices are ADR 0001, ADR 0002, and ADR 0003.
| Area | Choice |
|---|---|
| Frontend | Next.js App Router, React, TypeScript, Tailwind CSS, shadcn/ui on Radix UI |
| Frontend data and testing | TanStack Query, React Hook Form + Zod, Vitest/RTL, Playwright |
| Backend | Java 21, Spring Boot, Spring Web MVC, Spring Security, Spring Data JPA/Hibernate |
| Durable data | Supabase managed PostgreSQL in production; PostgreSQL with Flyway migrations |
| Ephemeral security and cache data | Redis for refresh sessions, JWT revocations, rate limits, and redirect cache |
| Security | Argon2id, Spring Security JOSE/Nimbus JWT, OAuth2 Client, CSRF, CORS, secure cookies |
| Local infrastructure | Docker Compose: PostgreSQL and Redis only |
| Production target | Vercel frontend; Hostinger VPS for Spring Boot, Redis, and Caddy; Supabase PostgreSQL (planned) |
backend/ Spring Boot API and its tests
src/main/java/com/tinyroute/
controller/ HTTP translation
service/ business rules and transactions
repository/ persistence contracts and Jpa* adapters
cache/ cache contracts and Redis* adapters
security/ JWT, cookies, filters, and guards
model/ JPA entities and value objects
config/ Spring configuration
src/main/resources/
db/migration/ Flyway schema migrations
application*.yml shared, dev, and prod configuration
frontend/ Next.js application, tests, and UI foundations
infra/vps/ planned VPS runtime boundary and environment example
docs/
requirements/ product and quality requirements
architecture/ authoritative system design and diagrams
decisions/ accepted architecture decisions
deployment/ VPS operations and future CI/CD instructions
development/ CI checks and local verification
spec/ feature specifications and contracts
tasks/ active plan and checklist for the current feature
compose.yml local PostgreSQL and Redis only
The Java package layout is layer-first across the application. Do not create per-feature Java package trees: controllers call services, services use repository and cache interfaces, JPA adapters own PostgreSQL access, and Redis adapters own Redis access.
- JDK 21
- Node.js 24 LTS and pnpm 12
- Docker Engine with Docker Compose
mkcertfor browser-trusted local HTTPS
cp .env.example .env
docker compose up -d
docker compose psCompose intentionally starts only PostgreSQL and Redis. PostgreSQL data is kept
in the named postgres-data volume; Redis development data is disposable. The
Spring Boot application runs separately through Maven or an IDE.
# Backend unit tests (no running datastores needed)
./backend/mvnw -f backend/pom.xml test
# Unit + integration/security tests, coverage and static analysis (requires Docker)
./backend/mvnw -f backend/pom.xml clean verify
# Check Java files changed since origin/main
./backend/mvnw -f backend/pom.xml spotless:check
# Frontend dependencies and checks
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend lint
pnpm --dir frontend format:check
pnpm --dir frontend typecheck
pnpm --dir frontend test --run
pnpm --dir frontend buildBuild from the repository root:
docker build -t tinyroute-backend:local backendThe Dockerfile builds the executable Spring Boot JAR with
Maven and Java 21, then copies it into a Java 21 JRE image running as UID/GID
10001. Both base images are pinned by digest; update those digests deliberately
when applying base-image updates. Maven dependencies use a BuildKit cache.
The build-context allowlist includes only pom.xml and
src/main, plus only target/application.jar for CI's verified-artifact target,
excluding local credentials, certificates, tests, and other build output.
This implements NFR-DEP-05/08 and NFR-SEC-03.
Run ./backend/mvnw -f backend/pom.xml clean verify before releasing an image.
Integration tests start disposable Testcontainers PostgreSQL and Redis on random
ports; they do not use or change local Compose data. Image packaging skips tests because its build has no
datastore services. For a host with a different CPU architecture, build with
the matching --platform value, for example --platform linux/amd64.
CI downloads the successfully verified JAR and builds --target ci; it scans
that runtime image without publishing it. The default target still builds from
source. See CI checks and required gates for workflows,
security thresholds, artifacts, and GitHub required-check setup.
Supply the profile, endpoints, JWT keys, OAuth credentials, and mail settings at
runtime through a protected environment file, using the configuration table
below. Set SPRING_PROFILES_ACTIVE=prod in that file. For example:
docker run --rm --name tinyroute-backend \
--env-file /absolute/path/backend.env tinyroute-backend:localAttach the container to the runtime's private network with --network when
connecting to Redis or an ingress proxy on that network. Container localhost
refers to the container itself; the development .env and dev profile target
host-local services and are not production container configuration. Mount any
required external CA certificate read-only at the configured path.
The image declares private HTTP port 8080; SERVER_PORT can override it.
Its health check calls /actuator/health on that port after a 60-second startup
grace period. Docker health status does not itself restart the container.
Keep public traffic behind the TLS-terminating ingress proxy. JVM options can
be supplied through JAVA_TOOL_OPTIONS; Java runs directly as PID 1 so it
receives shutdown signals.
Spring profiles are never hard-coded. Use SPRING_PROFILES_ACTIVE=dev for local
infrastructure and SPRING_PROFILES_ACTIVE=prod with the production Supabase
endpoint, private Redis, and externally supplied secrets. application-dev.yml reads the
Compose ports on localhost; application-prod.yml expects every endpoint and
secret from the environment.
The committed .env.example and frontend/.env.example contain placeholders
only. Docker Compose reads root .env automatically; Spring Boot does not. For
local development, replace the security/OAuth/mail placeholders and export the
file before starting Maven:
set -a
. ./.env
set +a
SPRING_PROFILES_ACTIVE=dev mvn -f backend/pom.xml spring-boot:runNever commit JWT signing keys, database credentials, OAuth credentials, email
credentials, or a populated .env/.env.local file.
The backend configuration names are explicit:
| Concern | Development and production environment variables |
|---|---|
| Profile/process | SPRING_PROFILES_ACTIVE; production may also set SERVER_PORT (default 8080 behind the TLS-terminating Caddy proxy) |
| PostgreSQL | Dev: POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_PORT; prod: DATABASE_URL, DATABASE_USERNAME, DATABASE_PASSWORD |
| Redis | Dev: REDIS_PORT; prod: REDIS_HOST, REDIS_PORT, and Spring's SPRING_DATA_REDIS_PASSWORD for the private Redis service |
| Browser/security | ALLOWED_FRONTEND_ORIGINS, RATE_LIMIT_HMAC_SECRET; production also requires TRUSTED_PROXY_CIDRS for the exact ingress ranges that append X-Forwarded-For |
| Short-code allocation | SHORT_LINK_BASE_URL, SHORT_CODE_KEY, SHORT_CODE_SALT; production requires a stable secret 32-byte key in canonical Base64 and a fixed 8–64 character ASCII salt ([A-Za-z0-9:_-]+) |
| JWT | TINYROUTE_JWT_ISSUER, TINYROUTE_JWT_AUDIENCE, TINYROUTE_JWT_ACTIVE_KEY_ID, TINYROUTE_JWT_SIGNING_PRIVATE_KEY_BASE64, TINYROUTE_JWT_ACTIVE_VERIFICATION_PUBLIC_KEY_BASE64; optional retired verification keys use SPRING_APPLICATION_JSON |
MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD, MAIL_SMTP_AUTH, MAIL_SMTP_STARTTLS, REGISTRATION_MAIL_FROM, PASSWORD_RESET_CONFIRMATION_URI |
|
| Google OAuth | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI, GOOGLE_SUCCESS_URI, GOOGLE_FAILURE_URI |
| Local TLS only | DEV_TLS_CERTIFICATE, DEV_TLS_PRIVATE_KEY |
The active verification key is associated with TINYROUTE_JWT_ACTIVE_KEY_ID.
During rotation, keep retired public keys until every JWT signed by them has
expired by adding the tinyroute.jwt.verification-public-keys map through
SPRING_APPLICATION_JSON, for example
{"tinyroute":{"jwt":{"verification-public-keys":{"retired-key-id":"<base64-x509-der>"}}}}.
The signing private key and all verification public keys are base64-encoded DER,
not PEM text.
Generated codes use Redis code:global and salted AES-FF1 over eight Base62
digits (FR-CRE-04/07). Distinct counter values produce distinct generated codes
under fixed key/salt configuration. Keep this key separate from JWT and rate-limit
secrets, and keep the key, salt, alphabet, width, and encoder version unchanged
across creators and deployments. The committed development values are public
local-only fixtures; production has no defaults. This feature provides no key
rotation or per-link salt protocol. Public short links remain discoverable URLs.
PostgreSQL stores the final code and committed numeric allocation. Normal allocation performs no database availability or high-water query. Missing Redis state or a confirmed generated-code conflict recovers from the maximum committed allocation, including deleted and expired rows. Aliases and legacy random codes have NULL allocation metadata and cannot raise that floor. Retain all code reservations. A malformed, expiring, or unavailable counter fails creation safely; there is no random or process-local fallback. Redirects do not use this allocator and still fall back to PostgreSQL on cache failure (NFR-REL-02).
From the repository root, create and trust a local certificate. The browser uses
https://localhost:3000; Next.js proxies API calls and short links to the local
Spring HTTP listener on port 8080. Spring remains the redirect authority.
For Google sign-in, create a Google OAuth client of type Web application and
add https://localhost:3000/api/auth/google/callback to its authorized redirect
URIs. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the backend's exported
environment. The redirect URI in Google Cloud must exactly match the backend's
GOOGLE_REDIRECT_URI, including scheme, port, and path.
TRUST_STORES=system mkcert -install
mkdir -p .local-certs
mkcert -cert-file .local-certs/localhost.pem -key-file .local-certs/localhost-key.pem localhost 127.0.0.1 ::1
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out .local-certs/jwt-private.pem
openssl pkcs8 -topk8 -nocrypt -in .local-certs/jwt-private.pem -outform DER -out .local-certs/jwt-private.der
openssl pkey -in .local-certs/jwt-private.pem -pubout -outform DER -out .local-certs/jwt-public.der
export TINYROUTE_JWT_SIGNING_PRIVATE_KEY_BASE64="$(openssl base64 -A -in .local-certs/jwt-private.der)"
export TINYROUTE_JWT_ACTIVE_VERIFICATION_PUBLIC_KEY_BASE64="$(openssl base64 -A -in .local-certs/jwt-public.der)"
SERVER_ADDRESS=127.0.0.1 SERVER_PORT=8080 SERVER_SSL_ENABLED=false SHORT_LINK_BASE_URL=https://localhost:3000 GOOGLE_REDIRECT_URI=https://localhost:3000/api/auth/google/callback SPRING_PROFILES_ACTIVE=dev mvn -f backend/pom.xml spring-boot:runRun the frontend in a second terminal from the repository root:
pnpm --dir frontend exec next dev --experimental-https --experimental-https-key ../.local-certs/localhost-key.pem --experimental-https-cert ../.local-certs/localhost.pemWith local HTTPS configured, run the deterministic browser journeys with Playwright's managed Chromium. These tests use disposable contract-shaped API responses and do not delete a real account:
pnpm --dir frontend exec playwright install chromium
pnpm --dir frontend exec playwright testVerify real Next.js routing against a disposable HTTP backend fixture, including 302/404 passthrough, page/asset isolation, and API cookie/CSRF forwarding:
pnpm --dir frontend exec playwright test --config playwright.routing.config.tsThis separate gate uses ports 3300 and 18080 and needs the local certificate above. It does not create accounts, access the database, or follow external redirects.
For the separate live browser/backend contract gate, run the real backend with
the direct HTTPS dev configuration on https://localhost:8443. The live gate's
managed frontend disables local rewrites and uses that HTTPS API (override
NEXT_PUBLIC_API_BASE_URL for another verification port). It starts its own
disposable SMTP listener on port 1025, creates a unique test account, captures
the OTP and reset email,
then verifies registration, session reload, logout, password reset, sign-in, and
account deletion across Next.js, Spring Security, PostgreSQL, and Redis. Keep
port 1025 free and configure the backend mail settings shown above:
pnpm --dir frontend exec playwright test --config playwright.live.config.tsThe link feature's live gate requires the test-only verification application,
not an ordinary development database. It launches PostgreSQL/Redis Testcontainers,
seeds three disposable accounts and 100 links, and exposes loopback/token-guarded
fixtures only on the test classpath. It is never packaged in the backend image.
Use existing local TLS certificates and externally activate dev. Supply a fresh
random TINYROUTE_VERIFICATION_TOKEN through the environment; keep it private and
use the same token in the browser/load terminals. Do not put it in command arguments
or tracked files. No production JWT keys are needed by this test entry point.
./backend/mvnw -f backend/pom.xml -B -ntp test-compile
SPRING_PROFILES_ACTIVE=dev ./backend/mvnw -f backend/pom.xml -B -ntp spring-boot:test-run -Dspring-boot.run.main-class=com.tinyroute.LinkVerificationApplication
pnpm --dir frontend exec playwright test --config playwright.live.config.tsDefaults are frontend/backend/SMTP ports 3000/8443/1025. If occupied, use an
isolated frontend checkout/copy with its own build output, and set
TINYROUTE_E2E_FRONTEND_PORT=3001, TINYROUTE_VERIFICATION_PORT=8444,
TINYROUTE_E2E_SMTP_PORT=1026, backend MAIL_PORT=1026, and
NEXT_PUBLIC_API_BASE_URL=https://localhost:8444. Match backend
ALLOWED_FRONTEND_ORIGINS=https://localhost:3001 and
PASSWORD_RESET_CONFIRMATION_URI=https://localhost:3001/password-reset/confirm.
Keep certificate references valid in that checkout. Never stop another developer's
server or point state fixtures at their datastores. The backend test pool is bounded
to four connections with zero minimum idle; production pool settings are unchanged.
The driver binds source 127.0.0.2, trusted only by this disposable application;
ordinary browser 127.0.0.1 traffic stays untrusted. On macOS, add a temporary
loopback alias with sudo /sbin/ifconfig lo0 alias 127.0.0.2 and remove it afterwards
with sudo /sbin/ifconfig lo0 -alias 127.0.0.2. On systems where 127/8 binding works
without an alias, no administrator setup is needed. The driver validates local TLS:
point NODE_EXTRA_CA_CERTS at the mkcert root CA, without disabling TLS validation.
An isolated Linux Docker network namespace also supports this protocol without a
macOS loopback alias. Run the test-only Java entry point there, and attach the
Node driver with --network container:<verification-backend>. Load the verified
test/runtime classpath and project from read-only mounts; use disposable
Testcontainers datastores. The driver still binds 127.0.0.2, validates TLS,
and uses the unchanged caps and trust settings. Record runtime images, hardware
and datastore placement alongside the report; this establishes local performance.
A native host driver may instead use --via-local-proxy. Publish only
127.0.0.1:8444 to a separate TCP listener in the backend's Linux namespace;
that listener forwards bytes to the loopback-bound Java port with source
127.0.0.2. The driver binds host loopback 127.0.0.1. Keep TLS end-to-end,
without terminating TLS or changing headers, and verify guarded fixture access
before traffic. Record this extra network hop; the backend's exact trust entry,
CSRF, normal caps and collection boundaries stay unchanged.
node --test tools/link-performance.test.mjs
node tools/link-performance.mjs --api-base https://localhost:8444 --rate 100 --duration 600 --warmup 30 --create-samples 200 --clients 100 --output .local-verification/link-performance.jsonThe driver creates missing output directories and a private report file before sending traffic, rejecting unusable output paths immediately. Existing evidence is preserved until a replacement report is ready. Completed request promises are released immediately; only the bounded active set is drained. Certificate and hostname validation remain enabled.
Run against freshly restarted disposable data if creation quotas were consumed by a prior measurement. The driver obtains CSRF for each creation, omits aliases, never retries POST, and never follows a redirect. It retains all statuses and transport failures, checks complete issuance/server timing samples, and fails on dropped arrivals or scheduling lateness above 100 ms. Server percentiles wrap the full Spring chain; client round trips are separate. Operational logs/metrics contain only fixed route, method, status and duration, with no link or owner identifiers. See the feature verification record for the passing local results and production verification limitation (NFR-PER-01–03).
Open https://localhost:3000/register. With frontend/.env.example copied to
frontend/.env.local, the browser calls https://localhost:3000/api/auth/csrf
and Next.js forwards it to http://localhost:8080/api/auth/csrf. Opening
https://localhost:3000/<code> forwards unmatched single-segment codes to
Spring, passing its 302 Location or HTML error directly to the browser.
Existing pages and static assets take priority; short links need no React page
or browser-side lookup. Spring's SHORT_LINK_BASE_URL must be
https://localhost:3000 so creation/listing return the same public origin.
Click counting is not added by these rewrites.
For direct HTTPS-backend development instead, unset frontend BACKEND_ORIGIN,
set NEXT_PUBLIC_API_BASE_URL=https://localhost:8443, and run Spring with its
default dev TLS settings and matching short-link/OAuth URLs. Maven runs Spring
with backend/ as its working directory, so the development profile reads
../.local-certs/.
An IDE with another working directory can set DEV_TLS_CERTIFICATE and
DEV_TLS_PRIVATE_KEY to absolute file: URLs. Keep .local-certs/ private.
The backend also needs its normal local database, Redis, JWT signing, and mail
configuration from the table above. Compose intentionally does not run an SMTP
server; use a separately managed local listener or configured development SMTP
account. If frontend/.env.local already exists, preserve its other settings and
set NEXT_PUBLIC_API_BASE_URL=https://localhost:3000 and
BACKEND_ORIGIN=http://localhost:8080 for same-origin development. Restart Next
after changing routing configuration. BACKEND_ORIGIN is server-only and must
point to Spring, never back to the frontend. Production uses the existing
API/go-host deployment below; leave this local rewrite configuration unset there.
For a fresh frontend checkout, copy frontend/.env.example to
frontend/.env.local before starting Next. Existing .env.local settings
should be preserved.
For production, use SPRING_PROFILES_ACTIVE=prod, a Supabase PostgreSQL JDBC
endpoint with verified TLS and source-IP restrictions, a private VPS Redis
endpoint, a cryptographically random rate-limit HMAC secret, SMTP authentication
with STARTTLS enabled, exact https://app.<zone> / https://api.<zone> origins,
and environment-managed JWT key material. Caddy on the Hostinger VPS terminates
public TLS; the private backend container listens on SERVER_PORT and must not
be publicly reachable. Supabase stores PostgreSQL data outside the VPS; Redis
uses its own persistent VPS volume, independent of backend releases. Use a
direct database connection when reachable or the session pooler on port 5432.
Mount the Supabase CA certificate for sslmode=verify-full; copy the actual
host/role from the project's Connect panel. Keep Spring authentication, JPA,
Flyway, and ddl-auto: validate; disable the unused Supabase Data API.
Production configuration starts from the VPS environment example,
not the development .env.
The Hostinger VPS deployment guide defines DNS, TLS, firewalls, Supabase connections/backup plans, automated daily encrypted database exports to Amazon S3, runtime secrets, logs, recovery, and the future GitHub Actions release sequence. Images will be published to private GitHub Container Registry and deployed by digest over host-verified SSH, with GitHub environment approval. Vercel CLI deployment is retained. The backend Dockerfile is available for image packaging. The guide and examples describe the accepted target; production Compose, release scripts, and workflows remain unimplemented. The Supabase-to-S3 backup contract defines the daily scheduler, restricted backup identity, retention, and restore checks. Its runtime installation remains deployment work.
- Passwords use Argon2id; raw passwords are never persisted.
- Access JWTs are RS256-signed, short-lived (15 minutes), key-ID-aware, and validated for issuer, audience, token type, expiry, and token version.
- Access and refresh tokens are sent only in host-only,
HttpOnly,Secure,SameSite=Laxcookies. Refresh tokens are stored in Redis only as hashes and use a 30-day sliding idle lifetime. - The API allows credentialed CORS only from configured frontend origins. Mutations require CSRF protection; the CSRF bootstrap response is not cached.
- Redis-backed authentication rate limits use HMAC-derived client-address keys
and accept forwarded addresses only from configured trusted proxies.
Direct local development leaves
TRUSTED_PROXY_CIDRSunset; production must provide the ingress proxy CIDRs that appendX-Forwarded-For. - PostgreSQL is the system of record. Redis is disposable operational state, so authorization must fail closed if required security state is unavailable.
The exact API and cookie rules for the active authentication feature are in its contracts.
| Read this | When you need to know |
|---|---|
| Functional requirements | Product behavior, MVP scope, and requirement IDs |
| Non-functional requirements | Performance, security, reliability, privacy, and deployability targets |
| Architecture | System boundaries, configuration, deployment topology, and design rules |
| Hostinger VPS deployment | Production prerequisites, operations, CI/CD, and release checks |
| Account-authentication specification | Current feature intent, acceptance criteria, and implementation approach |
| Account-authentication contracts | HTTP, cookie, JWT, CSRF, OAuth, and session semantics |
| Authentication checklist | Completed tasks and acceptance checks for authentication |
| Design guide | Frontend presentation rules only |
Start feature work from a clean, current main branch and use
feature/<feature-name>. Keep one concern per branch; do not implement or
commit directly on main. Before designing or implementing, read the relevant
requirements and architecture documents, and cite the matching FR-* or
NFR-* identifier for non-trivial work.
Keep controllers thin, put policy and transactions in services, and use repository/cache interfaces at persistence boundaries. Add a dependency only when a stated requirement needs it. See AGENTS.md for the complete project workflow and guardrails.