Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 5 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ A single-region, production-oriented URL shortener built with Spring Boot and An
- Rate limiting (token bucket)
- Soft delete support
- Custom aliases (feature-flagged)
- Enhanced observability

[![v2 HLD](diagrams/docs/architecture/00-baseline/v2/url-shortener-v2-hld.svg)](diagrams/docs/architecture/00-baseline/v2/url-shortener-v2-hld.svg)

Expand All @@ -46,7 +45,7 @@ A single-region, production-oriented URL shortener built with Spring Boot and An
| Migrations | Flyway |
| Reverse proxy | Nginx |
| Containerization | Docker, Docker Compose |
| Cloud | AWS (EC2, RDS, ALB, S3, CloudFront, Route 53, SSM) |
| Cloud | AWS (EC2, RDS, ALB, S3, CloudFront) |
| CI/CD | GitHub Actions → GHCR → EC2 via SSM |
| Observability | Micrometer, Prometheus, CloudWatch |

Expand All @@ -58,8 +57,6 @@ A single-region, production-oriented URL shortener built with Spring Boot and An
| --- | --- | --- |
| `POST` | `/api/urls` | Shorten a URL |
| `GET` | `/{shortCode}` | Redirect to original URL |
| `GET` | `/actuator/health` | Health check |
| `GET` | `/actuator/prometheus` | Metrics |

---

Expand All @@ -69,29 +66,23 @@ A single-region, production-oriented URL shortener built with Spring Boot and An

- Docker & Docker Compose
- Java 21 (for running backend without Docker)
- Node 20+ (for running frontend without Docker)

### Full stack (backend + database + nginx)

```bash
# Copy and fill in required env vars
cp .env.example .env

docker compose up --build
```

App available at `http://localhost:8080`.

### Backend only (with local Postgres)
### Backend only

```bash
cd tinyurl
./gradlew bootRun
```

Backend runs on `http://localhost:8080` by default.

### Run backend tests
### Run tests

```bash
cd tinyurl
Expand All @@ -102,17 +93,6 @@ cd tinyurl

---

## Environments

| Environment | How to run | URL |
| --- | --- | --- |
| Local (full stack) | `docker compose up` from repo root | `http://localhost:8080` |
| Local (backend only) | `./gradlew bootRun` in `tinyurl/` | `http://localhost:8080` |
| Local (frontend dev) | See [tinyurl-gui/README.md](tinyurl-gui/README.md) | `http://localhost:4200` |
| Production | Auto-deploy on merge to `main` | [go.buffden.com](https://go.buffden.com) |

---

## Project Structure

```text
Expand All @@ -123,23 +103,13 @@ infra/
postgres/ # DB init scripts
docs/
architecture/ # ADRs and architecture docs
deployment/ # AWS deployment phases (A–F)
deployment/ # AWS deployment runbook (phases A–F)
diagrams/ # Architecture diagrams (SVG)
docker-compose.yml # Local dev stack
docker-compose.prod.yml # Production stack (no Postgres — uses RDS)
```

---

## Deployment

Production runs on AWS (`us-east-1`). See [`docs/deployment/`](docs/deployment/README.md) for the full deployment runbook (infrastructure provisioning, secrets, CI/CD, observability, hardening).

```text
Route 53
tinyurl.buffden.com → CloudFront → S3 (Angular SPA)
go.buffden.com → ALB → EC2 (Nginx + Spring Boot) → RDS PostgreSQL
```

Docker image: `ghcr.io/buffden/tinyurl-api`
Deploy trigger: merge to `main` via GitHub Actions
Production is deployed on AWS. See [`docs/deployment/`](docs/deployment/README.md) for the full runbook.
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ services:
postgres:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: dev
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/tinyurl
SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME:?SPRING_DATASOURCE_USERNAME is required}
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?SPRING_DATASOURCE_PASSWORD is required}
Expand Down
5 changes: 5 additions & 0 deletions infra/nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ http {
listen 80;
server_name _;

add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

location / {
proxy_pass http://backend_upstream;
proxy_http_version 1.1;
Expand Down
18 changes: 11 additions & 7 deletions infra/postgres/init/002_app_user.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@

set -e

# Escape single quotes in credentials to prevent SQL injection
APP_USER="${SPRING_DATASOURCE_USERNAME//\'/\'\'}"
APP_PASS="${SPRING_DATASOURCE_PASSWORD//\'/\'\'}"

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE USER $SPRING_DATASOURCE_USERNAME WITH PASSWORD '$SPRING_DATASOURCE_PASSWORD';
GRANT CONNECT ON DATABASE $POSTGRES_DB TO $SPRING_DATASOURCE_USERNAME;
GRANT USAGE ON SCHEMA public TO $SPRING_DATASOURCE_USERNAME;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE url_mappings TO $SPRING_DATASOURCE_USERNAME;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO $SPRING_DATASOURCE_USERNAME;
CREATE USER "$APP_USER" WITH PASSWORD '$APP_PASS';
GRANT CONNECT ON DATABASE "$POSTGRES_DB" TO "$APP_USER";
GRANT USAGE ON SCHEMA public TO "$APP_USER";
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE url_mappings TO "$APP_USER";
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO "$APP_USER";
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO $SPRING_DATASOURCE_USERNAME;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "$APP_USER";
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO $SPRING_DATASOURCE_USERNAME;
GRANT USAGE, SELECT ON SEQUENCES TO "$APP_USER";
EOSQL
6 changes: 6 additions & 0 deletions tinyurl/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ RUN chmod +x gradlew && ./gradlew --no-daemon bootJar
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY --from=build /workspace/build/libs/*.jar /app/app.jar

RUN chown appuser:appgroup /app/app.jar

USER appuser

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "/app/app.jar"]
6 changes: 5 additions & 1 deletion tinyurl/src/main/java/com/tinyurl/config/AppProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.List;

@ConfigurationProperties(prefix = "tinyurl")
public record AppProperties(
String baseUrl,
Integer defaultExpiryDays,
Integer shortCodeMinLength
Integer shortCodeMinLength,
Cors cors
) {
public record Cors(List<String> allowedOrigins) {}
}
10 changes: 6 additions & 4 deletions tinyurl/src/main/java/com/tinyurl/config/CorsConfig.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.tinyurl.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
Expand All @@ -12,13 +11,16 @@
@Configuration
public class CorsConfig {

@Value("${tinyurl.cors.allowed-origins}")
private List<String> allowedOrigins;
private final AppProperties appProperties;

public CorsConfig(AppProperties appProperties) {
this.appProperties = appProperties;
}

@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(allowedOrigins);
config.setAllowedOrigins(appProperties.cors().allowedOrigins());
config.setAllowedMethods(List.of("GET", "POST", "OPTIONS"));
config.setAllowedHeaders(List.of("Content-Type", "Accept"));
config.setAllowCredentials(false);
Expand Down
9 changes: 9 additions & 0 deletions tinyurl/src/main/resources/application-dev.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Development-only overrides.
# Activate with: SPRING_PROFILES_ACTIVE=dev
# Never use this profile in production or CI/CD environments.

management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
3 changes: 2 additions & 1 deletion tinyurl/src/main/resources/application-prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ management:

tinyurl:
cors:
allowed-origins: "https://tinyurl.buffden.com"
allowed-origins:
- "https://tinyurl.buffden.com"
7 changes: 4 additions & 3 deletions tinyurl/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ management:
endpoints:
web:
exposure:
# Default: expose metrics for dev/test. Restricted in production via application-prod.yaml
include: health,metrics,prometheus
# Restricted to health by default. Metrics/prometheus enabled via application-dev.yaml
include: health
metrics:
tags:
application: ${spring.application.name}
Expand All @@ -45,4 +45,5 @@ tinyurl:
default-expiry-days: ${TINYURL_DEFAULT_EXPIRY_DAYS:180}
short-code-min-length: ${TINYURL_SHORT_CODE_MIN_LENGTH:6}
cors:
allowed-origins: "http://localhost:4200"
allowed-origins:
- "http://localhost:4200"
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class UrlServiceImplTest {

@BeforeEach
void setUp() {
service = new UrlServiceImpl(urlRepository, base62Encoder, new AppProperties("http://localhost", 180, 6));
service = new UrlServiceImpl(urlRepository, base62Encoder, new AppProperties("http://localhost", 180, 6, null));
}

@Test
Expand Down
Loading