diff --git a/sk1/.env.example b/sk1/.env.example new file mode 100644 index 0000000..20e9076 --- /dev/null +++ b/sk1/.env.example @@ -0,0 +1,16 @@ +# ─── Database ──────────────────────────────────────────────────────────────── +DB_NAME=shortlink +DB_USER=shortlink_user +DB_PASSWORD=CHANGE_THIS_STRONG_PASSWORD_123 + +# ─── Application ───────────────────────────────────────────────────────────── +BASE_URL=https://yourdomain.com +SECRET_KEY=CHANGE_THIS_TO_A_RANDOM_32_CHAR_STRING +ADMIN_TOKEN=CHANGE_THIS_ADMIN_TOKEN + +# ─── Deployment (used by prepare-app.sh) ───────────────────────────────────── +DOMAIN=yourdomain.com +EMAIL=your@email.com + +# ─── Backup (optional) ─────────────────────────────────────────────────────── +BACKUP_DIR=./backups diff --git a/sk1/.gitignore b/sk1/.gitignore new file mode 100644 index 0000000..275184e --- /dev/null +++ b/sk1/.gitignore @@ -0,0 +1,8 @@ +# Never commit secrets or generated files +.env +nginx/nginx.conf +backups/ +*.sql.gz +__pycache__/ +*.pyc +.DS_Store diff --git a/sk1/Dockerfile b/sk1/Dockerfile new file mode 100644 index 0000000..f2b1335 --- /dev/null +++ b/sk1/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies first (better layer caching) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY main.py . + +EXPOSE 8000 + +# Run with auto-reload disabled in production +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/sk1/README.md b/sk1/README.md new file mode 100644 index 0000000..d97dac0 --- /dev/null +++ b/sk1/README.md @@ -0,0 +1,233 @@ +# ShortLink — URL Shortener with Analytics + +## Description + +ShortLink is a self-hosted URL shortening service with a real-time analytics dashboard. +Users paste a long URL, receive a short link (e.g. `https://yourdomain.com/s/abc123`), +and can track how many times the link was visited, when, and from where. +The application targets teams and developers who need a private, self-controlled link shortener +without relying on third-party services like bit.ly. + +--- + +## Cloud Infrastructure + +**Cloud provider:** Google Cloud Platform (GCP) + +| Component | GCP Service / Tool | Purpose | +|---|---|---| +| Virtual machine | Compute Engine e2-small | Hosts all containers | +| Static IP | Compute Engine – External IP | Fixed address for DNS | +| Firewall | VPC Firewall rules | Allow HTTP (80) and HTTPS (443) | +| DNS | Any registrar → A record | Maps domain to the VM IP | +| SSL certificate | Let's Encrypt via Certbot | Free automatic HTTPS certificate | +| Container runtime | Docker Engine | Runs all application containers | +| Orchestration | Docker Compose | Manages multi-container lifecycle | + +**Docker objects:** + +| Container | Image | Role | +|---|---|---| +| `shortlink_nginx` | `nginx:alpine` | Reverse proxy, SSL termination, serves static frontend | +| `shortlink_backend` | Built from `./backend/Dockerfile` | FastAPI REST API, business logic | +| `shortlink_db` | `postgres:15-alpine` | Relational database (links + visits tables) | + +**Named volumes:** + +| Volume | Mounted in | Contents | +|---|---|---| +| `postgres_data` | `/var/lib/postgresql/data` | All database data (persistent) | +| `nginx_logs` | `/var/log/nginx` | Access and error logs | + +**Network:** All three containers share a Docker bridge network (`app_network`) so they can reach each other by container name (e.g. `backend:8000`, `db:5432`). + +--- + +## Cost Analysis (1 000 daily users, 50 GB data) + +Estimated traffic: ~1 000 requests/day → ~30 000/month (light load). + +| Resource | Specification | Monthly price | Annual price | +|---|---|---|---| +| Compute Engine e2-small | 2 vCPU, 2 GB RAM, region us-central1 | $13.60 | $163 | +| Persistent disk (SSD boot) | 30 GB | $5.10 | $61 | +| Additional SSD (database) | 50 GB | $8.50 | $102 | +| External IP address (static) | 1 address | $7.30 | $88 | +| Egress traffic | ~50 GB/month outbound | $4.50 | $54 | +| SSL certificate | Let's Encrypt | $0 | $0 | +| **Total** | | **~$39** | **~$468** | + +> Prices based on GCP us-central1 on-demand pricing (2024). Costs can be reduced ~37% by using a 1-year committed-use discount. +> Snapshot for backup: ~$0.026/GB/month × 50 GB = $1.30/month extra. + +--- + +## Files + +``` +sk1/ +├── prepare-app.sh # Deploy: installs Docker, Certbot, gets SSL cert, starts app +├── remove-app.sh # Teardown: stops and removes all containers and volumes +├── docker-compose.yml # Defines all three services, volumes and network +├── .env.example # Template for required environment variables (copy to .env) +├── README.md # This file +│ +├── backend/ +│ ├── Dockerfile # Builds Python 3.11-slim image with FastAPI +│ ├── requirements.txt # Python dependencies (fastapi, uvicorn, psycopg2) +│ └── main.py # REST API: POST /api/shorten, GET /api/stats, +│ # DELETE /api/links/{code}, GET /s/{code} +│ +├── init-db/ +│ └── init.sql # Creates tables (links, visits) and indexes on first run +│ +├── nginx/ +│ ├── nginx.conf.template # Nginx config with DOMAIN_PLACEHOLDER (filled by prepare-app.sh) +│ └── html/ +│ └── index.html # Single-page frontend (vanilla JS, dark theme) +│ +└── scripts/ + └── backup.sh # Dumps the database to a gzip file, keeps last 7 +``` + +--- + +## Configuration + +All runtime secrets and settings live in `.env` (never committed to Git). + +| Variable | Description | +|---|---| +| `DB_NAME` | PostgreSQL database name | +| `DB_USER` | PostgreSQL user | +| `DB_PASSWORD` | PostgreSQL password (secret) | +| `BASE_URL` | Public URL shown in short links (e.g. `https://yourdomain.com`) | +| `SECRET_KEY` | Application secret for future JWT use (secret) | +| `ADMIN_TOKEN` | Bearer token required to delete links via API (secret) | +| `DOMAIN` | Domain name used by Certbot and nginx (e.g. `yourdomain.com`) | +| `EMAIL` | Email for Let's Encrypt certificate notifications | +| `BACKUP_DIR` | Where backups are written (default: `./backups`) | + +`docker-compose.yml` reads these via `${VAR}` substitution and passes them as container environment variables. No secrets appear in any source file or in Git. + +--- + +## Deployment Instructions + +### Prerequisites + +- A GCP Compute Engine VM running Ubuntu 22.04 LTS (e2-small or larger) +- An external static IP assigned to the VM +- Firewall rules allowing TCP 80 and TCP 443 +- A domain name with an A record pointing to the VM IP +- SSH access to the VM + +### Steps + +```bash +# 1. SSH into the VM +gcloud compute ssh --zone + +# 2. Clone or copy the project to the VM +git clone +cd sk1 + +# 3. Create and fill in the environment file +cp .env.example .env +nano .env # fill in all values + +# 4. Make scripts executable +chmod +x prepare-app.sh remove-app.sh scripts/backup.sh + +# 5. Run the deployment script +./prepare-app.sh + +# Done — visit https:// +``` + +### Using the application + +1. Open `https://` in a web browser. +2. Paste any URL into the input field and click **Shorten**. +3. Copy the short link and share it — every click is tracked. +4. The **Analytics** table below shows all links with visit counts. +5. To use a custom short code, click **⚙ Custom code** before shortening. +6. To delete a link, click **Del** in the table and enter the `ADMIN_TOKEN`. + +--- + +## Backup + +```bash +# Run the backup script (from the sk1 directory) +./scripts/backup.sh + +# Backups are saved as ./backups/shortlink_YYYYMMDD_HHMMSS.sql.gz +# The last 7 backups are kept automatically. + +# To restore: +gunzip -c backups/shortlink_.sql.gz \ + | docker exec -i shortlink_db psql -U "$DB_USER" -d "$DB_NAME" +``` + +--- + +## Viewing Access Logs + +```bash +# Live nginx access log (HTTP requests from the internet) +docker exec shortlink_nginx tail -f /var/log/nginx/access.log + +# Or from the named volume on the host +docker run --rm -v shortlink_nginx_logs:/logs alpine tail -f /logs/access.log + +# Last 100 lines of access log +docker exec shortlink_nginx tail -100 /var/log/nginx/access.log + +# Error log +docker exec shortlink_nginx tail -f /var/log/nginx/error.log + +# All container logs (backend API logs including each redirect) +docker compose logs -f +``` + +--- + +## Stopping / Removing the Application + +```bash +./remove-app.sh +``` + +This stops and removes containers, the Docker network, and named volumes (database is deleted). SSL certificates are preserved. + +--- + +## Script Conditions + +### prepare-app.sh + +- Must be run on a **Ubuntu 22.04** server (GCP Compute Engine VM or equivalent) +- The VM must have **ports 80 and 443 open** in the firewall +- **DNS** must be configured: the domain's A record must point to the VM's external IP **before running the script** (Certbot validates this) +- `.env` file must exist with all required variables filled in +- Internet access required (to pull Docker images, install packages, contact Let's Encrypt) +- The script is idempotent: safe to run again if interrupted + +### remove-app.sh + +- Must be run from the `sk1` directory on the same VM +- Docker and Docker Compose must be installed + +--- + +## External Resources + +| Resource | Type | Usage | +|---|---|---| +| FastAPI documentation (fastapi.tiangolo.com) | Official docs | API routing, response models, middleware | +| Docker Compose file reference (docs.docker.com) | Official docs | Service configuration, health checks, volumes | +| Certbot documentation (certbot.eff.org) | Official docs | `--standalone` certificate issuance | +| Nginx documentation (nginx.org) | Official docs | Reverse proxy config, SSL, logging | +| PostgreSQL 15 docs (postgresql.org) | Official docs | SQL schema, pg_dump backup | +| **Claude (Anthropic)** | Generative AI | Used for: generating boilerplate FastAPI route stubs, suggesting nginx proxy_pass configuration, drafting SQL schema, explaining Let's Encrypt certbot flags, reviewing shell script error handling. All generated output was reviewed, tested and adapted by the author. | diff --git a/sk1/backup.sh b/sk1/backup.sh new file mode 100644 index 0000000..6b610b8 --- /dev/null +++ b/sk1/backup.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# scripts/backup.sh — Back up the ShortLink PostgreSQL database. +# Run from the sk1 directory: ./scripts/backup.sh +# Optionally set BACKUP_DIR in .env to change the backup location. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}/.." + +# Load env +set -a +# shellcheck disable=SC1091 +source .env +set +a + +BACKUP_DIR="${BACKUP_DIR:-./backups}" +TIMESTAMP="$(date +"%Y%m%d_%H%M%S")" +BACKUP_FILE="${BACKUP_DIR}/shortlink_${TIMESTAMP}.sql.gz" + +mkdir -p "${BACKUP_DIR}" + +echo "Creating database backup..." +docker exec shortlink_db pg_dump \ + -U "${DB_USER}" \ + -d "${DB_NAME}" \ + --no-owner \ + --clean \ + | gzip > "${BACKUP_FILE}" + +SIZE="$(du -sh "${BACKUP_FILE}" | cut -f1)" +echo "✓ Backup saved: ${BACKUP_FILE} (${SIZE})" + +# Keep only the 7 most recent backups +KEPT=7 +OLDER=$(ls -t "${BACKUP_DIR}"/shortlink_*.sql.gz 2>/dev/null | tail -n +"$((KEPT + 1))") +if [ -n "$OLDER" ]; then + echo "$OLDER" | xargs rm -f + echo " Old backups removed (keeping last ${KEPT})." +fi + +# ── To restore a backup ─────────────────────────────────────────────────────── +# gunzip -c backups/shortlink_.sql.gz \ +# | docker exec -i shortlink_db psql -U "$DB_USER" -d "$DB_NAME" diff --git a/sk1/docker-compose.yml b/sk1/docker-compose.yml new file mode 100644 index 0000000..73d8c65 --- /dev/null +++ b/sk1/docker-compose.yml @@ -0,0 +1,63 @@ +version: '3.8' + +services: + db: + image: postgres:15-alpine + container_name: shortlink_db + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init-db/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro + networks: + - app_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: shortlink_backend + restart: unless-stopped + environment: + DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} + SECRET_KEY: ${SECRET_KEY} + BASE_URL: ${BASE_URL} + ADMIN_TOKEN: ${ADMIN_TOKEN} + depends_on: + db: + condition: service_healthy + networks: + - app_network + + nginx: + image: nginx:alpine + container_name: shortlink_nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/html:/usr/share/nginx/html:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + - nginx_logs:/var/log/nginx + depends_on: + - backend + networks: + - app_network + +networks: + app_network: + driver: bridge + +volumes: + postgres_data: + nginx_logs: diff --git a/sk1/index.html b/sk1/index.html new file mode 100644 index 0000000..cc9db71 --- /dev/null +++ b/sk1/index.html @@ -0,0 +1,312 @@ + + + + + + ShortLink — URL Shortener + + + + +
+ +

ShortLink

+ Shorten, share & track URLs +
+ +
+ + +
+

✂ Shorten a URL

+
+ + +
+ ⚙ Custom code / title +
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+

📊 Analytics

+ +
+
+
Loading…
+
+ +
+ +
ShortLink — Cloud Deployment Assignment · Built with FastAPI + PostgreSQL + Nginx
+ + + + diff --git a/sk1/init.sql b/sk1/init.sql new file mode 100644 index 0000000..3b153d4 --- /dev/null +++ b/sk1/init.sql @@ -0,0 +1,23 @@ +-- ShortLink database schema + +CREATE TABLE IF NOT EXISTS links ( + id SERIAL PRIMARY KEY, + code VARCHAR(20) UNIQUE NOT NULL, + original_url TEXT NOT NULL, + title VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by_ip VARCHAR(45) +); + +CREATE TABLE IF NOT EXISTS visits ( + id SERIAL PRIMARY KEY, + link_id INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE, + visited_at TIMESTAMP NOT NULL DEFAULT NOW(), + ip_address VARCHAR(45), + user_agent TEXT, + referer TEXT +); + +CREATE INDEX IF NOT EXISTS idx_links_code ON links(code); +CREATE INDEX IF NOT EXISTS idx_visits_link_id ON visits(link_id); +CREATE INDEX IF NOT EXISTS idx_visits_visited ON visits(visited_at); diff --git a/sk1/main.py b/sk1/main.py new file mode 100644 index 0000000..7478339 --- /dev/null +++ b/sk1/main.py @@ -0,0 +1,190 @@ +from fastapi import FastAPI, HTTPException, Request, Header +from fastapi.responses import RedirectResponse +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from datetime import datetime +import psycopg2 +import psycopg2.pool +import os +import string +import random +import logging + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +app = FastAPI(title="ShortLink API", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +DATABASE_URL = os.environ["DATABASE_URL"] +BASE_URL = os.environ.get("BASE_URL", "http://localhost") +ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "") + +_pool = None + + +def get_pool(): + global _pool + if _pool is None: + _pool = psycopg2.pool.SimpleConnectionPool(1, 10, DATABASE_URL) + return _pool + + +def get_client_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For") + return forwarded.split(",")[0].strip() if forwarded else request.client.host + + +def generate_code(length: int = 6) -> str: + chars = string.ascii_letters + string.digits + return "".join(random.choices(chars, k=length)) + + +# ── Models ──────────────────────────────────────────────────────────────────── + +class URLCreate(BaseModel): + url: str + custom_code: str | None = None + title: str | None = None + + +# ── Routes ──────────────────────────────────────────────────────────────────── + +@app.get("/health") +def health(): + return {"status": "ok", "timestamp": datetime.utcnow().isoformat()} + + +@app.post("/api/shorten") +def shorten_url(data: URLCreate, request: Request): + url = data.url.strip() + if not url.startswith(("http://", "https://")): + url = "https://" + url + + code = (data.custom_code or "").strip() or generate_code() + + if not code.replace("-", "").replace("_", "").isalnum(): + raise HTTPException(status_code=400, detail="Code may only contain letters, digits, hyphens and underscores") + + pool = get_pool() + conn = pool.getconn() + try: + cur = conn.cursor() + cur.execute("SELECT id FROM links WHERE code = %s", (code,)) + if cur.fetchone(): + raise HTTPException(status_code=409, detail="That short code is already in use") + + ip = get_client_ip(request) + cur.execute( + "INSERT INTO links (code, original_url, title, created_at, created_by_ip) VALUES (%s, %s, %s, %s, %s)", + (code, url, data.title, datetime.utcnow(), ip), + ) + conn.commit() + cur.close() + logger.info("Created link %s -> %s", code, url) + return {"short_url": f"{BASE_URL}/s/{code}", "code": code, "original_url": url} + except HTTPException: + raise + except Exception as exc: + conn.rollback() + logger.error("Error creating link: %s", exc) + raise HTTPException(status_code=500, detail="Internal server error") + finally: + pool.putconn(conn) + + +@app.get("/api/stats") +def get_stats(): + pool = get_pool() + conn = pool.getconn() + try: + cur = conn.cursor() + cur.execute(""" + SELECT l.code, l.original_url, l.title, l.created_at, + COUNT(v.id) AS visit_count, + MAX(v.visited_at) AS last_visit + FROM links l + LEFT JOIN visits v ON l.id = v.link_id + GROUP BY l.id + ORDER BY visit_count DESC, l.created_at DESC + LIMIT 50 + """) + rows = cur.fetchall() + + cur.execute("SELECT COUNT(*) FROM links") + total_links = cur.fetchone()[0] + + cur.execute("SELECT COUNT(*) FROM visits") + total_visits = cur.fetchone()[0] + cur.close() + + return { + "total_links": total_links, + "total_visits": total_visits, + "links": [ + { + "code": r[0], + "original_url": r[1], + "title": r[2], + "created_at": r[3].isoformat(), + "visit_count": r[4], + "last_visit": r[5].isoformat() if r[5] else None, + "short_url": f"{BASE_URL}/s/{r[0]}", + } + for r in rows + ], + } + finally: + pool.putconn(conn) + + +@app.delete("/api/links/{code}") +def delete_link(code: str, authorization: str = Header(None)): + if not authorization or authorization != f"Bearer {ADMIN_TOKEN}": + raise HTTPException(status_code=401, detail="Unauthorized") + + pool = get_pool() + conn = pool.getconn() + try: + cur = conn.cursor() + cur.execute("DELETE FROM links WHERE code = %s RETURNING id", (code,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Link not found") + conn.commit() + cur.close() + logger.info("Deleted link %s", code) + return {"message": f"Link '{code}' deleted"} + finally: + pool.putconn(conn) + + +@app.get("/s/{code}") +def redirect_link(code: str, request: Request): + pool = get_pool() + conn = pool.getconn() + try: + cur = conn.cursor() + cur.execute("SELECT id, original_url FROM links WHERE code = %s", (code,)) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Short link not found") + + link_id, original_url = row + cur.execute( + "INSERT INTO visits (link_id, visited_at, ip_address, user_agent, referer) VALUES (%s, %s, %s, %s, %s)", + (link_id, datetime.utcnow(), + get_client_ip(request), + request.headers.get("User-Agent", "")[:500], + request.headers.get("Referer", "")[:500]), + ) + conn.commit() + cur.close() + return RedirectResponse(url=original_url, status_code=302) + finally: + pool.putconn(conn) diff --git a/sk1/nginx.conf.template b/sk1/nginx.conf.template new file mode 100644 index 0000000..04ee2ea --- /dev/null +++ b/sk1/nginx.conf.template @@ -0,0 +1,70 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Access log format – captures real client IP via X-Forwarded-For + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + gzip on; + gzip_types text/plain text/css application/json application/javascript; + sendfile on; + + # ── HTTP → HTTPS redirect ───────────────────────────────────────────────── + server { + listen 80; + server_name DOMAIN_PLACEHOLDER; + return 301 https://$host$request_uri; + } + + # ── HTTPS server ────────────────────────────────────────────────────────── + server { + listen 443 ssl; + server_name DOMAIN_PLACEHOLDER; + + ssl_certificate /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + access_log /var/log/nginx/access.log main; + + # Static frontend (served by nginx directly) + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + # Backend API + location /api/ { + proxy_pass http://backend:8000; + 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; + } + + # Short link redirects + location /s/ { + proxy_pass http://backend:8000; + 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; + } + + # Health check + location /health { + proxy_pass http://backend:8000; + } + } +} diff --git a/sk1/prepare-app.sh b/sk1/prepare-app.sh new file mode 100644 index 0000000..392d78a --- /dev/null +++ b/sk1/prepare-app.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# prepare-app.sh — Deploy ShortLink to Google Cloud (Ubuntu 22.04 VM) +# Conditions: Run on a fresh GCP Compute Engine VM with Ubuntu 22.04. +# Port 80 and 443 must be open in firewall. +# DNS A record for DOMAIN must already point to this VM's external IP. +# The .env file must exist (copy from .env.example and fill in values). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "======================================================" +echo " ShortLink — Cloud Deployment Script" +echo "======================================================" +echo "" + +# ── 1. Load and validate environment ───────────────────────────────────────── +if [ ! -f ".env" ]; then + echo "ERROR: .env file not found!" + echo " Run: cp .env.example .env" + echo " Then edit .env with your values." + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +source .env +set +a + +: "${DOMAIN:?Set DOMAIN in .env (e.g. shortlink.example.com)}" +: "${EMAIL:?Set EMAIL in .env (used for SSL certificate)}" +: "${DB_NAME:?Set DB_NAME in .env}" +: "${DB_USER:?Set DB_USER in .env}" +: "${DB_PASSWORD:?Set DB_PASSWORD in .env}" +: "${BASE_URL:?Set BASE_URL in .env (e.g. https://shortlink.example.com)}" + +echo "[ENV] Domain: $DOMAIN" +echo "[ENV] Base URL: $BASE_URL" +echo "" + +# ── 2. Install Docker ───────────────────────────────────────────────────────── +if ! command -v docker &>/dev/null; then + echo "[1/5] Installing Docker..." + curl -fsSL https://get.docker.com | sudo sh + sudo usermod -aG docker "$USER" + # Re-exec with docker group applied so the rest of the script can use docker + exec sg docker "$0" "$@" +else + echo "[1/5] Docker already installed: $(docker --version)" +fi + +# ── 3. Install Docker Compose plugin ───────────────────────────────────────── +if ! docker compose version &>/dev/null 2>&1; then + echo "[2/5] Installing Docker Compose plugin..." + sudo apt-get update -qq + sudo apt-get install -y docker-compose-plugin +else + echo "[2/5] Docker Compose installed: $(docker compose version)" +fi + +# ── 4. Install Certbot ──────────────────────────────────────────────────────── +if ! command -v certbot &>/dev/null; then + echo "[3/5] Installing Certbot..." + sudo apt-get update -qq + sudo apt-get install -y certbot +else + echo "[3/5] Certbot already installed: $(certbot --version)" +fi + +# ── 5. Obtain SSL certificate ───────────────────────────────────────────────── +if [ ! -d "/etc/letsencrypt/live/${DOMAIN}" ]; then + echo "[4/5] Obtaining SSL certificate for ${DOMAIN}..." + echo " (Certbot will temporarily listen on port 80 — ensure it is open)" + sudo certbot certonly \ + --standalone \ + --non-interactive \ + --agree-tos \ + --email "${EMAIL}" \ + -d "${DOMAIN}" + echo " Certificate obtained successfully." +else + echo "[4/5] SSL certificate already exists for ${DOMAIN}." +fi + +# ── 6. Generate nginx.conf from template ────────────────────────────────────── +echo "[5/5] Generating nginx configuration..." +sed "s/DOMAIN_PLACEHOLDER/${DOMAIN}/g" nginx/nginx.conf.template > nginx/nginx.conf + +# ── 7. Build and start containers ──────────────────────────────────────────── +echo "[5/5] Building images and starting containers..." +docker compose up -d --build + +# ── 8. Configure automatic SSL renewal ─────────────────────────────────────── +APP_DIR="$SCRIPT_DIR" +RENEW_CMD="certbot renew --quiet --pre-hook 'docker stop shortlink_nginx' --post-hook 'docker start shortlink_nginx' >> /var/log/certbot-renew.log 2>&1" +(sudo crontab -l 2>/dev/null | grep -v certbot; echo "0 3 * * 0 $RENEW_CMD") | sudo crontab - +echo " SSL auto-renewal cron job configured (every Sunday 03:00)." + +echo "" +echo "╔══════════════════════════════════════════════╗" +echo "║ ✅ Deployment Successful! ║" +echo "╚══════════════════════════════════════════════╝" +echo "" +echo " Application URL : https://${DOMAIN}" +echo " Health endpoint : https://${DOMAIN}/health" +echo "" +echo "Container status:" +docker compose ps +echo "" +echo "To view logs: docker compose logs -f" +echo "To stop: ./remove-app.sh" +echo "To backup: ./scripts/backup.sh" diff --git a/sk1/remove-app.sh b/sk1/remove-app.sh new file mode 100644 index 0000000..9d0cd49 --- /dev/null +++ b/sk1/remove-app.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# remove-app.sh — Stop and remove all ShortLink containers, networks and volumes. +# Conditions: Run from the sk1 directory on the VM where prepare-app.sh was executed. +# Docker and Docker Compose must be installed. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "======================================================" +echo " ShortLink — Removal Script" +echo "======================================================" +echo "" +echo "This will:" +echo " • Stop all ShortLink containers" +echo " • Remove containers, networks, and named volumes (database data)" +echo " • SSL certificates will NOT be removed" +echo "" +read -rp "Are you sure? [y/N] " confirm +if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 0 +fi + +echo "" +echo "[1/2] Stopping and removing containers, networks and volumes..." +docker compose down -v --remove-orphans + +echo "[2/2] Removing built images..." +docker compose images -q 2>/dev/null | xargs -r docker rmi -f 2>/dev/null || true + +echo "" +echo "✅ Application removed successfully." +echo "" +echo "Note: SSL certificates in /etc/letsencrypt were NOT removed." +echo "To remove them manually:" +echo " sudo certbot delete --cert-name ${DOMAIN:-your-domain}" diff --git a/sk1/requirements.txt b/sk1/requirements.txt new file mode 100644 index 0000000..1ba8035 --- /dev/null +++ b/sk1/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.0 +psycopg2-binary==2.9.9 +pydantic==2.7.1