A minimal, production-ready URL-shortening service with free & premium tiers. Built with FastAPI + PostgreSQL (dev-friendly defaults included).
🚀 Gateway Hub — Minimal URL‑Shortening SaaS (Free & Premium Tiers) Gateway Hub is a lightweight, production‑ready SaaS application that provides URL shortening with free and premium subscription tiers. It is built with FastAPI + SQLAlchemy + PostgreSQL, and can run on low-cost VPS infrastructure or container platforms.
This project is intentionally minimal, easy to deploy, and designed to be extended into a full SaaS product.
✨ Features Free Tier Create up to 5 short links per month
Up to 100 clicks per link
Personal dashboard for managing links
Premium Tier Unlimited short links
Unlimited clicks
Click analytics (basic)
Custom domain support (demo placeholder)
Priority usage limits
Note: Payment flow is mocked for demonstration. The mock upgrade endpoint is disabled when ENVIRONMENT=production unless ENABLE_MOCK_BILLING=true is explicitly set for a controlled demo. In production, integrate Stripe, PayPal, or another billing provider.
🏗️ Active Tech Stack Python 3.10+
FastAPI backend (app.main:app)
SQLAlchemy + Alembic migrations
PostgreSQL primary database
Werkzeug password hashing
React + TypeScript frontend
📁 Active Project Structure
Код
Gateway-Hub/
│
├── app/ # FastAPI backend package (app.main:app)
├── src/ # React/TypeScript frontend
├── migrations/ # Alembic migrations for PostgreSQL
├── docker/ # Frontend Nginx container config
├── nginx/ # Production reverse proxy config
├── requirements.txt # Backend dependencies
├── package.json # Frontend dependencies
└── README.md # Project documentation
📦 Installation
Clone the repository:
bash git clone https://github.com/AlexKitipov/Gateway-Hub cd Gateway-Hub Install dependencies:
bash pip install -r requirements.txt 🗄️ Database Setup Run migrations (configure your database URL first in environment variables):
bash alembic upgrade head
This creates the required PostgreSQL schema. The application does not create or update tables at runtime; run Alembic migrations before starting or restarting the API in every deployment.
bash uvicorn app.main:app --reload The API will be available at:
Start the React frontend separately with the package scripts in package.json when you need the browser UI.
🔐 Security Notes
Replace the default dev-secret-key with a secure key in production. The application refuses to start when ENVIRONMENT=production and SECRET_KEY is blank or still set to a placeholder value.
Keep ENABLE_MOCK_BILLING=false in production so demo premium upgrades cannot grant paid access. The Stripe placeholder settings (STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID, STRIPE_SUCCESS_URL, and STRIPE_CANCEL_URL) are reserved for the production billing integration.
Configure short-link generation with SHORT_CODE_LENGTH, SHORT_CODE_ALPHABET, and SHORT_URL_BASE (for example, https://yourdomain.com/r). ALLOWED_ORIGINS accepts a comma-separated list such as https://app.example.com,https://admin.example.com.
Always run behind HTTPS.
Consider adding:
CSRF protection for browser-only flows
Structured logging & monitoring enhancements
Reverse proxy (Nginx) + Gunicorn for production
📈 Future Enhancements Real payment integration (Stripe / PayPal)
Custom domain routing
QR code generation
REST API for programmatic shortening
Admin dashboard
Email notifications
Analytics dashboard with charts
📝 License This project is licensed under the MIT License. You are free to use, modify, and deploy it commercially.
All authenticated and management API endpoints are versioned under /api/v1. Public short-link redirects are served separately at /r/{code} so they stay short and do not collide with the links API.
| Method | Endpoint | Auth | Rate Limit | Description |
|---|---|---|---|---|
| POST | /api/v1/auth/register |
❌ | 5/min | Register new user |
| POST | /api/v1/auth/login |
❌ | 10/min | User login |
| POST | /api/v1/auth/logout |
✅ | 100/min | User logout |
| POST | /api/v1/auth/refresh |
❌ | 10/min | Refresh access token |
Register
POST /api/v1/auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!",
"full_name": "John Doe"
}Response 201:
{
"user": {
"id": 1,
"email": "user@example.com",
"full_name": "John Doe",
"is_premium": false,
"is_active": true,
"premium_until": null,
"created_at": "2024-01-15T10:30:00Z"
},
"access_token": "eyJhbGc...",
"refresh_token": "eyJhbGc...",
"token_type": "bearer"
}Login
POST /api/v1/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123!"
}Response 200:
{
"user": { "...": "..." },
"access_token": "eyJhbGc...",
"refresh_token": "eyJhbGc...",
"token_type": "bearer"
}| Method | Endpoint | Auth | Rate Limit | Description |
|---|---|---|---|---|
| GET | /api/v1/users/stats |
✅ | 100/min | Get user stats |
| POST | /api/v1/users/upgrade |
✅ | 1/min | Upgrade to premium |
Get Stats
GET /api/v1/users/stats
Authorization: Bearer <token>Response 200:
{
"total_links": 12,
"total_clicks": 1523,
"links_this_month": 3,
"is_premium": false,
"premium_until": null
}Upgrade
POST /api/v1/users/upgrade
Authorization: Bearer <token>Response 200 (development/demo mock billing only):
{
"success": true,
"message": "Upgraded to premium",
"premium_until": "2025-01-15T10:30:00Z"
}Response 403 when ENVIRONMENT=production and ENABLE_MOCK_BILLING=false:
{
"detail": "Mock premium upgrades are disabled in production. Configure Stripe billing or set ENABLE_MOCK_BILLING=true only for controlled demos."
}| Method | Endpoint | Auth | Rate Limit | Description |
|---|---|---|---|---|
| GET | /api/v1/links/ |
✅ | 100/min | Get user's links |
| POST | /api/v1/links/create |
✅ | 30/min | Create new link |
| DELETE | /api/v1/links/{code} |
✅ | 100/min | Delete link |
| GET | /api/v1/links/{code} |
✅ | 100/min | Get link details |
Create Link
POST /api/v1/links/create
Authorization: Bearer <token>
Content-Type: application/json
{
"target_url": "https://example.com/very-long-url",
"title": "Example Link",
"description": "My test link",
"custom_code": null
}Response 201:
{
"id": 42,
"code": "abc123",
"target_url": "https://example.com/very-long-url",
"title": "Example Link",
"description": "My test link",
"click_count": 0,
"is_active": true,
"created_at": "2024-01-15T10:30:00Z",
"short_url": "https://sh.rt/abc123"
}Get All Links
GET /api/v1/links/?skip=0&limit=50
Authorization: Bearer <token>Response 200:
{
"total": 12,
"limit": 50,
"offset": 0,
"links": [
{
"id": 42,
"code": "abc123",
"target_url": "https://example.com/...",
"title": "Example Link",
"click_count": 15,
"is_active": true,
"created_at": "2024-01-15T10:30:00Z",
"short_url": "https://sh.rt/abc123"
}
]
}Delete Link
DELETE /api/v1/links/abc123
Authorization: Bearer <token>Response 200:
{
"success": true,
"message": "Link deleted successfully"
}Public Redirect
GET /r/abc123Response: 301 Moved Permanently
Location: https://example.com/very-long-urlPublic redirects are intentionally outside the /api/v1 management API and are handled by the dedicated redirect router.
| Method | Endpoint | Auth | Rate Limit | Description |
|---|---|---|---|---|
| GET | /r/{code} |
❌ | 1000/min | Redirect to the target URL and record click analytics |
| Method | Endpoint | Auth | Rate Limit | Description |
|---|---|---|---|---|
| GET | /api/v1/analytics/{code} |
✅ | 100/min | Get link analytics |
Get Analytics
GET /api/v1/analytics/abc123?days=30
Authorization: Bearer <token>Response 200:
{
"link_code": "abc123",
"total_clicks": 1523,
"unique_ips": 256,
"top_countries": [["US", 450], ["GB", 300], ["CA", 200]],
"top_referrers": [["https://twitter.com", 500], ["https://reddit.com", 400], ["Direct", 623]],
"clicks_by_date": [["2024-01-15", 50], ["2024-01-16", 75], ["2024-01-17", 120]],
"clicks_by_hour": [[0, 5], [1, 3], [8, 45], [12, 120], [23, 8]]
}All errors use:
{
"success": false,
"message": "Error description",
"error_code": "ERROR_CODE_CONSTANT"
}| Code | Status | Meaning |
|---|---|---|
INVALID_CREDENTIALS |
401 | Wrong email/password |
INVALID_TOKEN |
401 | JWT token expired or invalid |
UNAUTHORIZED |
403 | No authentication provided |
USER_NOT_FOUND |
404 | User does not exist |
LINK_NOT_FOUND |
404 | Short link does not exist |
EMAIL_ALREADY_EXISTS |
400 | Email already registered |
LIMIT_EXCEEDED |
403 | Free tier limit reached |
CLICK_LIMIT_EXCEEDED |
403 | Free tier click limit exceeded |
CODE_TAKEN |
400 | Custom code already in use |
RATE_LIMIT_EXCEEDED |
429 | Too many requests |
Recommended Specs:
- OS: Ubuntu 22.04 LTS
- CPU: 1 vCore
- RAM: 2GB
- Storage: 50GB SSD
- Cost: $6-12/month
Initial Setup (non-interactive):
The repository includes deploy.sh, which is designed to be rerunnable and suitable for automation. It installs packages, creates the application user if needed, clones or fast-forwards the repository, creates or preserves the production environment file, runs Alembic migrations after that environment is available, installs systemd/Nginx, and restarts services.
# Run on the VPS as root, or via sudo from an administrator account.
sudo APP_USER=appuser \
APP_DIR=/home/appuser/backend \
REPO_URL=https://github.com/AlexKitipov/Gateway-Hub.git \
REPO_BRANCH=main \
./deploy.shOptional settings:
RUN_APT_UPGRADE=truealso applies package upgrades; by default the script only updates package metadata and installs missing dependencies.ENABLE_SSL=true DOMAIN=api.example.com CERTBOT_EMAIL=admin@example.comrequests a Let's Encrypt certificate without prompts.ENV_FILE=/etc/gateway-hub/gateway-hub.envoverrides the systemd environment file location.
The first run creates /etc/gateway-hub/gateway-hub.env with mode 0640, owner root, and group appuser. It generates a unique SECRET_KEY instead of committing or hardcoding one. Review the file before exposing the service, especially ALLOWED_ORIGINS and any provider credentials.
Example environment file:
DATABASE_URL=postgresql:///gateway_hub
SECRET_KEY=<generated-or-secret-manager-value>
DEBUG=false
ENVIRONMENT=production
ALLOWED_ORIGINS=https://app.example.com
REDIS_URL=redis://localhost:6379Migrations must run with the same environment as the service. deploy.sh sources /etc/gateway-hub/gateway-hub.env before running ./scripts/migrate.sh, so schema changes are applied to the configured production database before the API is restarted.
# /etc/systemd/system/gateway-hub.service
[Unit]
Description=Gateway Hub API Server
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=appuser
WorkingDirectory=/home/appuser/backend
Environment="PATH=/home/appuser/backend/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
EnvironmentFile=/etc/gateway-hub/gateway-hub.env
ExecStart=/home/appuser/backend/venv/bin/uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--workers 4 \
--timeout-keep-alive 65 \
--access-log
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetType=simple matches the direct uvicorn process above. Use Type=notify only if you add a notify-capable wrapper that sends systemd readiness notifications.
Commands:
sudo systemctl daemon-reload
sudo systemctl enable gateway-hub
sudo systemctl start gateway-hub
sudo systemctl status gateway-hub
sudo journalctl -u gateway-hub -f # View logs# /etc/nginx/sites-available/gateway-hub
upstream gateway_hub_backend {
least_conn;
server 127.0.0.1:8000 max_fails=5 fail_timeout=30s;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name api.yourdomain.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.yourdomain.com;
# SSL certificates (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Compression
gzip on;
gzip_vary on;
gzip_min_length 1000;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
# Logging
access_log /var/log/nginx/gateway-hub-access.log;
error_log /var/log/nginx/gateway-hub-error.log;
# API endpoints
location /api/ {
proxy_pass http://gateway_hub_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Public redirect endpoint (short URLs)
location ~ ^/r/[a-zA-Z0-9_-]+$ {
proxy_pass http://gateway_hub_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend (separate domain)
location / {
return 404; # API-only, frontend on separate domain
}
}Setup SSL with Let's Encrypt:
sudo apt install certbot python3-certbot-nginx
sudo certbot certonly --nginx -d api.yourdomain.comDocker Compose can run the complete local stack: FastAPI backend, React frontend, PostgreSQL, Redis, and an optional Nginx reverse proxy.
Services included:
postgres— PostgreSQL 15 database used by the backend.redis— Redis 7 cache/rate-limit store used by the backend.migrate— one-shot Alembic migration job that runs before the backend starts.backend— FastAPI API served by Uvicorn onhttp://localhost:8000by default.frontend— production React static build served by Nginx onhttp://localhost:3000by default.nginx— optional reverse proxy profile onhttp://localhost:8080that routes/api/and/r/to the backend and all other requests to the frontend.
Prepare environment variables:
cp .env.example .envThe backend uses synchronous SQLAlchemy/psycopg2, so Compose uses a sync PostgreSQL URL:
DATABASE_URL=postgresql://appuser:password123@postgres:5432/gateway_hub
REDIS_URL=redis://redis:6379
REACT_APP_API_URL=http://localhost:8000Start the default local stack:
docker compose up --buildThe migrate job runs alembic upgrade head automatically after PostgreSQL is healthy. The backend waits for that job to complete successfully before it starts, so a normal docker compose up creates a coherent application stack without a separate migration command.
Open the application:
- Frontend:
http://localhost:3000 - Backend health check:
http://localhost:8000/health - API base path:
http://localhost:8000/api/v1
Run with the optional Nginx reverse proxy:
docker compose --profile proxy up --buildWhen the proxy profile is enabled, open http://localhost:8080. Nginx serves the frontend and proxies API calls to the backend from the same origin.
Database and Redis ports:
PostgreSQL and Redis are intentionally not published to the host by default. They are reachable by other Compose services on the internal Docker network as postgres:5432 and redis:6379, which avoids exposing database/cache ports in production-like runs. If you need host access for a local database client, add a temporary docker-compose.override.yml such as:
services:
postgres:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"Useful commands:
# Re-run migrations manually if needed
docker compose run --rm migrate
# Follow backend logs
docker compose logs -f backend
# Stop and remove containers, keeping named volumes
docker compose down
# Stop and remove containers plus database/cache volumes
docker compose down -v# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_pass
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run tests
env:
DATABASE_URL: postgresql://test_user:test_pass@localhost:5432/test_db
run: pytest tests/ --cov=app
- name: Upload coverage
uses: codecov/codecov-action@v3
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Deploy to VPS
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: appuser
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd ~/backend
git pull origin main
source venv/bin/activate
pip install -r requirements.txt
alembic upgrade head
systemctl restart gateway-hub
systemctl status gateway-hub# Structured logging setup
import logging
from pythonjsonlogger import jsonlogger
logHandler = logging.FileHandler('logs/app.json')
formatter = jsonlogger.JsonFormatter()
logHandler.setFormatter(formatter)
logger = logging.getLogger()
logger.addHandler(logHandler)
logger.setLevel(logging.INFO)
# Log important events
logger.info("User registered", extra={
"user_id": user.id,
"email": user.email,
"timestamp": datetime.utcnow().isoformat()
})
logger.error("Link creation failed", extra={
"user_id": user_id,
"reason": str(e),
"timestamp": datetime.utcnow().isoformat()
})Monitoring Tools (Optional):
- Uptime Monitoring: Uptimerobot.com (free)
- Application Monitoring: Sentry.io (free tier available)
- Log Aggregation: Papertrail, Datadog, ELK Stack
- Performance: New Relic, Datadog APM