Current release: v3.0.0. The canonical local address is
http://goshorty.localhost:8080; public deployments use the hostname supplied
through PUBLIC_BASE_URL.
A production-ready URL shortener service built with Go, featuring automatic expiration (TTL) support with configurable time windows from 5 minutes to 7 days.
Production hardening includes origin-allowlisted CORS, browser security headers, per-route rate limiting, a 1 MiB request-body limit, authenticated password rotation, and separate liveness/PostgreSQL readiness checks.
- ✅ Create short URLs with automatic TTL expiration
- ✅ Multiple TTL options: 5m, 15m, 1h, 24h, 7 days (168h)
- ✅ Custom short codes (optional)
- ✅ Visit tracking and analytics
- ✅ RESTful API
- ✅ PostgreSQL persistence in production
- ✅ In-memory storage fallback for local development
- ✅ CORS enabled
- ✅ Production-ready error handling
GoShorty/
├── main.go # Application entry point
├── config/
│ └── config.go # Configuration management
├── models/
│ └── url.go # Data models
├── services/
│ └── url_service.go # Business logic
├── handlers/
│ └── handler.go # HTTP request handlers
├── storage/
│ ├── interfaces.go # Repository contracts
│ ├── storage.go # In-memory development storage
│ ├── postgres.go # PostgreSQL production storage
│ └── migrations/ # Embedded database migrations
├── utils/
│ └── random.go # Utility functions
├── go.mod # Go module file
├── middleware.go # Security headers, CORS, request body limits
├── observability.go # Structured logs, metrics, panic recovery
├── api_spec.go # Embedded OpenAPI 3.1 document + route
├── main_test.go # Route-layer and SPA fallback tests
├── frontend_test.go # SPA asset regression tests
├── static/
│ ├── index.html # SPA markup (no inline script/style)
│ ├── css/style.css # SPA stylesheet
│ └── js/app.js # SPA logic — canonical /api/v1, session restore
├── e2e/
│ ├── package.json # Playwright dependency + scripts (pinned version)
│ ├── playwright.config.ts # chromium (CI) + local-chrome projects
│ └── tests/
│ └── spa.spec.ts # SPA happy path + session-restore specs
└── README.md # This file
- Go 1.26.8 or higher (includes required standard-library security fixes)
- Windows, macOS, or Linux
Recommended environment bootstrap:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/setup-env.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/doctor.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/dev.ps1On Linux/macOS/WSL:
bash scripts/setup-env.sh
bash scripts/doctor.sh
bash scripts/dev.shThe scripts start PostgreSQL and run GoShorty with the ignored .env.local.
They use Docker Compose (PostgreSQL 16) when Docker is available and fall back
to an embedded PostgreSQL instance (cmd/localdb) when it is not. See
ENVIRONMENTS.md for development, test, CI, and Railway
production configuration.
- Navigate to the project directory:
cd GoShorty- Install dependencies:
go mod download- Set a development signing key and run the application:
# PowerShell
$env:SECRET_KEY="development-secret-change-me"
go run .
# Linux/macOS
SECRET_KEY="development-secret-change-me" go run .The server will start on http://goshorty.localhost:8080 when using the
generated local environment. http://localhost:8080 remains usable for direct
access.
GitHub Actions runs formatting, module verification, vet, unit tests, coverage
thresholds, a Linux race detector, govulncheck, PostgreSQL integration tests,
and an end-to-end API flow for every pull request and push to main.
Local equivalents:
go test ./... -count=1
go vet ./...
go test ./... -covermode=atomic -coverprofile=coverage.out
bash scripts/check-coverage.sh coverage.out 30See INTEGRATION_TESTING.md for PostgreSQL, race, and E2E instructions.
/api/v1/* is the canonical API surface; the legacy /api/* routes remain
fully functional as backward-compatible aliases. The canonical short-link
redirect format is /r/:code (/s/:code and /goshorty/:timeout/:code
still work). See API_REFERENCE.md for the complete
reference.
A machine-readable OpenAPI 3.1 description of the full surface is served at
GET /api/v1/openapi.json (JSON); run the server to view it
(http://localhost:8080/api/v1/openapi.json).
Endpoint: POST /api/v1/urls (legacy alias: POST /api/shorten)
Request Body:
{
"url": "https://example.com/very/long/url/that/needs/shortening",
"expires_in": "24h",
"custom_code": "mycode"
}Parameters:
url(required): The original URL to shortenexpires_in(optional): TTL - one of:5m,15m,1h,24h,168h(default:24h)custom_code(optional): Custom short code (must be alphanumeric, max 50 chars)
Response:
{
"id": "a1b2c3d4e5f6g7h8",
"short_url": "http://localhost:8080/r/mycode",
"short_code": "mycode",
"original_url": "https://example.com/very/long/url",
"expires_in": "24h",
"expires_at": "2026-02-17T12:00:00Z",
"created_at": "2026-02-16T12:00:00Z"
}Endpoint: GET /r/:code
Example: GET /r/mycode
Redirects to the original URL if not expired. Returns 404 if expired or not
found. Backward-compatible aliases: GET /s/:code and
GET /goshorty/:timeout/:code.
Endpoint: GET /api/v1/urls/:code (legacy alias: GET /api/shorten/:code)
Response:
{
"id": "a1b2c3d4e5f6g7h8",
"short_code": "mycode",
"original_url": "https://example.com/very/long/url",
"expires_in": "24h",
"expires_at": "2026-02-17T12:00:00Z",
"created_at": "2026-02-16T12:00:00Z",
"visits": 5
}Endpoint: DELETE /api/v1/urls/:code (legacy alias: DELETE /api/shorten/:code)
Response:
{
"message": "URL deleted successfully",
"code": "mycode"
}Endpoint: GET /api/v1/urls (legacy alias: GET /api/shorten/all)
Paginated: limit (1–200, default 50) and cursor (from next_cursor)
query parameters; responds with next_cursor and total. The legacy alias
returns the full list without pagination fields. URLs are ordered newest
first.
Response:
{
"urls": [...]
}Endpoint: GET /api/v1/stats (legacy alias: GET /api/stats)
Response:
{
"total_urls": 42,
"total_visits": 156
}Endpoint: GET /health
Response:
{
"status": "ok",
"service": "goshorty"
}Endpoint: GET /ready
Returns 200 only when required production dependencies, including PostgreSQL,
are reachable. Railway uses this endpoint for deployment health checks.
Endpoint: GET /metrics
Returns Prometheus-compatible HTTP request counters and duration summaries.
Every response also carries X-Request-ID, which matches the structured JSON
request log. Release deployments require
Authorization: Bearer <METRICS_TOKEN>.
Endpoint: GET /version
Returns the application version, source commit, and build timestamp.
Operational procedures, backup/restore drills, alerts, and deployment verification are documented in OPERATIONS.md. Security boundaries and token revocation semantics are documented in SECURITY.md.
Endpoint: PUT /api/auth/password
Requires Authorization: Bearer <token>.
{
"current_password": "current-password",
"new_password": "new-unique-password"
}The new password must be between 12 and 72 bytes.
Create a short URL:
curl -X POST http://localhost:8080/api/v1/urls \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.google.com",
"expires_in": "1h",
"custom_code": "google"
}'Get URL info:
curl http://localhost:8080/api/v1/urls/googleDelete short URL:
curl -X DELETE http://localhost:8080/api/v1/urls/google// Create short URL
const response = await fetch('http://localhost:8080/api/v1/urls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://www.example.com',
expires_in: '24h'
})
});
const data = await response.json();
console.log(data.short_url);| Option | Duration | Use Case |
|---|---|---|
| 5m | 5 minutes | Quick temporary links |
| 15m | 15 minutes | Short-lived sharing |
| 1h | 1 hour | Same-day links |
| 24h | 1 day | Default option |
| 168h | 7 days | Long-term sharing |
- Production: PostgreSQL selected through
DATABASE_URL - Development: Thread-safe in-memory fallback when not in release mode
- Migrations: Embedded, versioned SQL migrations run automatically at startup
- Consistency: Unique short-code constraint, transactional admin invariants, atomic visit counters
- Cleanup: Expired URLs are removed in bounded background batches
- Login: 10 requests/minute/IP
- Registration: 5 requests/hour/IP (configurable via
REGISTER_LIMIT_PER_HOUR) - Password changes: 5 requests/hour/IP
- URL creation: 60 requests/minute/IP
- Redirects: 300 requests/minute/IP
- Request bodies: maximum 1 MiB
- CORS defaults to
PUBLIC_BASE_URL; override with comma-separatedALLOWED_ORIGINS - Trusted reverse proxies default to Railway's
100.64.0.0/10; override with comma-separatedTRUSTED_PROXIES - HTTP timeouts: 5s headers, 15s read/write, 60s idle
- Single-page app served from the embedded
static/tree: markup (index.html), stylesheet (static/css/style.css) and logic (static/js/app.js) are split; no inline<script>oronclick. - Targets the canonical
/api/v1/*surface. - Validates the stored session on load via
GET /api/v1/auth/meand handles401globally as an expired session (auto logout). - Runs under the strict
script-src 'self'Content Security Policy.
- Thread-safe operations using
sync.RWMutex - Goroutine for background cleanup
- Safe for concurrent read/write access
- Indexed short-code and expiry lookups
- PostgreSQL connection pooling through pgx
- Horizontal instances share the same persistent data
Request → Router → Handler → Service → Storage
↑ ↓
Config ← ← ← ← ← ← ← ←
- Handlers: Accept HTTP requests, validate input
- Services: Implement business logic, coordinate operations
- Storage: Manage data persistence and TTL
- Config: Centralized configuration management
- Redis backend support for distributed deployments
- Database persistence (PostgreSQL)
- User authentication and role-based access
- Analytics dashboard
- Frontend session hardening (session restore, strict CSP, asset split)
- Batch URL shortening
- QR code generation
- URL preview feature
- Advanced rate limiting
- Webhook support
- Docker containerization
go build -o goshorty.exe./goshorty.exego test ./...Production configuration is provided through environment variables:
SECRET_KEY— required token signing key.ADMIN_PASSWORD— required in release mode.DATABASE_URL— required in release mode.PUBLIC_BASE_URL— canonical public origin for short links.ADMIN_EMAIL— optional bootstrap admin email.TOKEN_TTL— optional token lifetime, default24h.TOKEN_ISSUER/TOKEN_AUDIENCE— optional claims that bind tokens to the deployment; empty = disabled (backward compatible), enabling invalidates previously issued tokens.SECRET_KEY_PREVIOUS— optional previous signing key for smooth key rotation; tokens signed with it keep verifying until the value is removed.PORT— listening port; Railway supplies this automatically.GIN_MODE=release— enables production mode.
The API returns appropriate HTTP status codes:
200 OK: Successful request201 Created: URL successfully created400 Bad Request: Invalid input404 Not Found: URL not found or expired500 Internal Server Error: Server error
MIT License - Feel free to use this project
For issues or questions, please open an issue in the repository.
GoShorty v1.0.0 - Built with Go & Gin Framework