Back to blog

Build a Video Platform: Project Setup

javaspring-bootreactnextjsvideo-streaming
Build a Video Platform: Project Setup

Time to write code. In this post, we'll go from an empty directory to a running development stack — Spring Boot API, Next.js frontend, PostgreSQL, and Redis, all wired together with Docker Compose and hot reload on both backend and frontend.

No authentication yet. No video uploads. No Stripe. Just a solid foundation with the right project structure, profiles, and development workflow. We'll layer everything else on top in later posts.

Time commitment: 1–2 hours
Prerequisites: Series Overview & Architecture

What we'll build in this post:
✅ Spring Boot API project with layered package structure
✅ Next.js frontend with App Router, TypeScript, and Tailwind CSS
✅ Docker Compose for PostgreSQL and Redis
✅ Development and production Spring profiles
✅ Health check endpoint and CORS configuration
✅ Hot reload workflow for both backend and frontend


Repository Structure

Here's the monorepo layout we'll build. Backend and frontend live side by side:

video-platform/
├── backend/                          # Spring Boot API
│   ├── src/main/java/dev/chanhle/courses/
│   │   ├── CoursesApplication.java   # Main entry point
│   │   ├── config/
│   │   │   └── CorsConfig.java       # CORS configuration
│   │   ├── health/
│   │   │   └── HealthController.java # Health check endpoint
│   │   └── common/
│   │       ├── dto/
│   │       │   └── ApiResponse.java  # Standard API response wrapper
│   │       └── exception/
│   │           ├── GlobalExceptionHandler.java
│   │           └── ResourceNotFoundException.java
│   ├── src/main/resources/
│   │   ├── application.yml           # Shared config
│   │   ├── application-dev.yml       # Development profile
│   │   └── application-prod.yml      # Production profile
│   ├── build.gradle                  # Gradle build config
│   ├── settings.gradle
│   └── Dockerfile
├── frontend/                         # Next.js app
│   ├── app/
│   │   ├── layout.tsx                # Root layout
│   │   ├── page.tsx                  # Home page
│   │   └── globals.css               # Global styles
│   ├── components/
│   │   └── Header.tsx                # Navigation header
│   ├── lib/
│   │   └── api.ts                    # Axios instance
│   ├── next.config.ts
│   ├── tailwind.config.ts
│   ├── tsconfig.json
│   ├── package.json
│   └── Dockerfile
├── docker-compose.yml                # Development stack
├── docker-compose.prod.yml           # Production stack (later)
├── .env.example
├── .gitignore
└── README.md

Why this structure:

  • Monorepo — backend and frontend in one repository. Simpler to manage, version, and deploy together
  • config/ — centralized configuration classes (CORS, security, Redis — we'll add more)
  • common/ — shared DTOs and exception handling, used across all domains
  • health/ — health check endpoint. Later domains (auth/, course/, video/, subscription/) follow the same pattern

Spring Boot Backend Setup

Generate the Project

Head to start.spring.io and create a project with these settings:

SettingValue
ProjectGradle - Groovy
LanguageJava
Spring Boot3.4.x (latest stable)
Groupdev.chanhle
Artifactcourses
Namecourses
Packagedev.chanhle.courses
Java17

Dependencies to add:

  • Spring Web
  • Spring Data JPA
  • PostgreSQL Driver
  • Spring Data Redis
  • Validation
  • Lombok
  • Spring Boot DevTools

Or use the command line:

mkdir video-platform && cd video-platform
 
curl https://start.spring.io/starter.zip \
  -d type=gradle-project \
  -d language=java \
  -d bootVersion=3.4.4 \
  -d groupId=dev.chanhle \
  -d artifactId=courses \
  -d name=courses \
  -d packageName=dev.chanhle.courses \
  -d javaVersion=17 \
  -d dependencies=web,data-jpa,postgresql,data-redis,validation,lombok,devtools \
  -o backend.zip
 
unzip backend.zip -d backend
rm backend.zip

Gradle Build Configuration

Open backend/build.gradle and verify the dependencies:

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.4'
    id 'io.spring.dependency-management' version '1.1.7'
}
 
group = 'dev.chanhle'
version = '0.0.1-SNAPSHOT'
 
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}
 
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}
 
repositories {
    mavenCentral()
}
 
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
 
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
 
    runtimeOnly 'org.postgresql:postgresql'
 
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
 
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
 
tasks.named('test') {
    useJUnitPlatform()
}

Why these dependencies:

  • spring-boot-starter-web — REST controllers, Jackson JSON, embedded Tomcat
  • spring-boot-starter-data-jpa — Hibernate ORM, repository pattern, transaction management
  • spring-boot-starter-data-redis — Redis client for caching and session storage
  • spring-boot-starter-validation — Bean validation (@Valid, @NotBlank, @Email)
  • lombok — reduce boilerplate (@Data, @Builder, @Slf4j)
  • devtools — automatic restart on code changes (hot reload)

Application Configuration

Spring Boot uses YAML profiles to separate configuration per environment. We'll create three files: shared, development, and production.

Shared Configuration

backend/src/main/resources/application.yml:

spring:
  application:
    name: video-courses-api
 
  profiles:
    active: dev
 
  jackson:
    default-property-inclusion: non_null
    serialization:
      write-dates-as-timestamps: false
 
server:
  port: 8080
  error:
    include-message: always
    include-binding-errors: always

Key settings:

  • default-property-inclusion: non_null — omit null fields from JSON responses (cleaner API)
  • write-dates-as-timestamps: false — serialize dates as ISO 8601 strings (2026-03-22T10:30:00Z)
  • include-message: always — include error messages in responses (useful for debugging)

Development Profile

backend/src/main/resources/application-dev.yml:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/courses_dev
    username: courses
    password: courses_dev_password
    hikari:
      maximum-pool-size: 5
 
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: true
    properties:
      hibernate:
        format_sql: true
 
  data:
    redis:
      host: localhost
      port: 6379
 
  devtools:
    restart:
      enabled: true
 
logging:
  level:
    dev.chanhle.courses: DEBUG
    org.hibernate.SQL: DEBUG

Key development settings:

  • ddl-auto: validate — validate schema against entities but don't modify it (Flyway handles migrations, which we'll add in Post #4)
  • show-sql: true — print SQL queries to console for debugging
  • maximum-pool-size: 5 — small connection pool for local development
  • DevTools restart — Spring Boot restarts automatically when .class files change

Production Profile

backend/src/main/resources/application-prod.yml:

spring:
  datasource:
    url: ${DATABASE_URL}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
 
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
 
  data:
    redis:
      host: ${REDIS_HOST:redis}
      port: ${REDIS_PORT:6379}
 
  devtools:
    restart:
      enabled: false
 
logging:
  level:
    dev.chanhle.courses: INFO
    org.hibernate.SQL: WARN
 
server:
  error:
    include-message: never
    include-stacktrace: never

Key production differences:

  • Environment variables — sensitive values come from ${DATABASE_URL}, not hardcoded
  • Larger connection pool — 20 max connections for production load
  • No SQL logging — avoid log noise
  • Error messages hidden — don't leak internal details to clients

Standard API Response Wrapper

Every API endpoint should return a consistent response format. Create a generic wrapper:

backend/src/main/java/dev/chanhle/courses/common/dto/ApiResponse.java:

package dev.chanhle.courses.common.dto;
 
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
 
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
    private boolean success;
    private String message;
    private T data;
    private Object errors;
 
    public static <T> ApiResponse<T> success(T data) {
        return ApiResponse.<T>builder()
                .success(true)
                .data(data)
                .build();
    }
 
    public static <T> ApiResponse<T> success(String message, T data) {
        return ApiResponse.<T>builder()
                .success(true)
                .message(message)
                .data(data)
                .build();
    }
 
    public static <T> ApiResponse<T> error(String message) {
        return ApiResponse.<T>builder()
                .success(false)
                .message(message)
                .build();
    }
 
    public static <T> ApiResponse<T> error(String message, Object errors) {
        return ApiResponse.<T>builder()
                .success(false)
                .message(message)
                .errors(errors)
                .build();
    }
}

Usage in controllers:

// Success response
return ResponseEntity.ok(ApiResponse.success(courseDto));
 
// Success with message
return ResponseEntity.status(HttpStatus.CREATED)
    .body(ApiResponse.success("Course created", courseDto));
 
// Error response
return ResponseEntity.status(HttpStatus.NOT_FOUND)
    .body(ApiResponse.error("Course not found"));

Example JSON output:

{
  "success": true,
  "message": "Course created",
  "data": {
    "id": 1,
    "title": "Spring Boot Fundamentals",
    "slug": "spring-boot-fundamentals"
  }
}

Global Exception Handler

Catch all exceptions in one place and return consistent error responses:

backend/src/main/java/dev/chanhle/courses/common/exception/ResourceNotFoundException.java:

package dev.chanhle.courses.common.exception;
 
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String resource, String field, Object value) {
        super(String.format("%s not found with %s: %s", resource, field, value));
    }
}

backend/src/main/java/dev/chanhle/courses/common/exception/GlobalExceptionHandler.java:

package dev.chanhle.courses.common.exception;
 
import dev.chanhle.courses.common.dto.ApiResponse;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
 
import java.util.HashMap;
import java.util.Map;
 
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
 
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleNotFound(ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ApiResponse.error(ex.getMessage()));
    }
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.error("Validation failed", errors));
    }
 
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(ConstraintViolationException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getConstraintViolations().forEach(violation ->
                errors.put(violation.getPropertyPath().toString(), violation.getMessage())
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.error("Validation failed", errors));
    }
 
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ApiResponse<Void>> handleBadRequest(IllegalArgumentException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.error(ex.getMessage()));
    }
 
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleGeneral(Exception ex) {
        log.error("Unexpected error", ex);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ApiResponse.error("An unexpected error occurred"));
    }
}

This gives us consistent error responses across the entire API:

{
  "success": false,
  "message": "Validation failed",
  "errors": {
    "email": "must not be blank",
    "title": "size must be between 3 and 255"
  }
}

CORS Configuration

The frontend (Next.js on port 3000) needs to make API calls to Spring Boot (port 8080). Without CORS, browsers block these cross-origin requests.

backend/src/main/java/dev/chanhle/courses/config/CorsConfig.java:

package dev.chanhle.courses.config;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
 
import java.util.List;
 
@Configuration
public class CorsConfig {
 
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.setAllowedOrigins(List.of(
                "http://localhost:3000",
                "http://localhost:3001"
        ));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setExposedHeaders(List.of("Authorization"));
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
 
        return new CorsFilter(source);
    }
}

Key settings:

  • allowCredentials: true — needed for HTTP-only cookies (refresh tokens in Post #3)
  • allowedOrigins — only our frontend can make requests. In production, this changes to the real domain
  • exposedHeaders: Authorization — the frontend needs to read the Authorization header from responses
  • /api/** — CORS only applies to API routes, not static file serving

Health Check Endpoint

A simple endpoint to verify the API is running. Useful for Docker health checks, load balancers, and monitoring.

backend/src/main/java/dev/chanhle/courses/health/HealthController.java:

package dev.chanhle.courses.health;
 
import dev.chanhle.courses.common.dto.ApiResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
import java.time.Instant;
import java.util.Map;
 
@RestController
@RequestMapping("/api")
public class HealthController {
 
    @GetMapping("/health")
    public ResponseEntity<ApiResponse<Map<String, Object>>> health() {
        Map<String, Object> healthData = Map.of(
                "status", "UP",
                "timestamp", Instant.now().toString(),
                "service", "video-courses-api"
        );
        return ResponseEntity.ok(ApiResponse.success(healthData));
    }
}

Test it:

curl http://localhost:8080/api/health
{
  "success": true,
  "data": {
    "status": "UP",
    "timestamp": "2026-03-22T10:30:00Z",
    "service": "video-courses-api"
  }
}

Next.js Frontend Setup

Create the Project

cd video-platform
 
npx create-next-app@latest frontend \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir=false \
  --import-alias="@/*" \
  --turbopack

This creates a Next.js 15 project with:

  • App Router — the modern routing system
  • TypeScript — type safety
  • Tailwind CSS — utility-first styling
  • Turbopack — fast development builds
  • No src/ directory — files directly under frontend/

Install Additional Dependencies

cd frontend
npm install axios
npm install -D @types/node
  • axios — HTTP client with interceptor support (crucial for token refresh later)

API Client

Create a reusable Axios instance that all API calls will use:

frontend/lib/api.ts:

import axios from 'axios';
 
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api';
 
const api = axios.create({
  baseURL: API_BASE_URL,
  headers: {
    'Content-Type': 'application/json',
  },
  withCredentials: true,
});
 
// Response interceptor — extract data or handle errors
api.interceptors.response.use(
  (response) => response.data,
  (error) => {
    const message = error.response?.data?.message || 'An unexpected error occurred';
    return Promise.reject(new Error(message));
  }
);
 
export default api;

Key settings:

  • withCredentials: true — send cookies with every request (needed for refresh tokens)
  • baseURL — all API calls are relative to this URL. In production, this points to the real API domain
  • Response interceptor — automatically unwraps the response and extracts the error message

We'll add a request interceptor in Post #3 to attach the JWT access token and handle automatic token refresh.

Environment Variables

frontend/.env.local:

NEXT_PUBLIC_API_URL=http://localhost:8080/api

The NEXT_PUBLIC_ prefix makes the variable available in client-side code.

Root Layout

frontend/app/layout.tsx:

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
 
const inter = Inter({ subsets: ['latin'] });
 
export const metadata: Metadata = {
  title: 'Video Courses',
  description: 'Learn programming with video courses',
};
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${inter.className} antialiased bg-gray-50 min-h-screen`}>
        <Header />
        <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
          {children}
        </main>
      </body>
    </html>
  );
}
 
function Header() {
  return (
    <header className="bg-white border-b border-gray-200">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="flex justify-between items-center h-16">
          <a href="/" className="text-xl font-bold text-gray-900">
            Video Courses
          </a>
          <nav className="flex items-center space-x-6">
            <a href="/courses" className="text-gray-600 hover:text-gray-900">
              Courses
            </a>
            <a
              href="/login"
              className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
            >
              Sign In
            </a>
          </nav>
        </div>
      </div>
    </header>
  );
}

Home Page

frontend/app/page.tsx:

'use client';
 
import { useEffect, useState } from 'react';
import api from '@/lib/api';
 
interface HealthData {
  success: boolean;
  data: {
    status: string;
    timestamp: string;
    service: string;
  };
}
 
export default function Home() {
  const [health, setHealth] = useState<HealthData | null>(null);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    api
      .get('/health')
      .then((data) => setHealth(data as unknown as HealthData))
      .catch((err) => setError(err.message));
  }, []);
 
  return (
    <div className="text-center py-20">
      <h1 className="text-4xl font-bold text-gray-900 mb-4">
        Video Course Platform
      </h1>
      <p className="text-lg text-gray-600 mb-8">
        Learn programming with high-quality video courses
      </p>
 
      <div className="max-w-md mx-auto bg-white rounded-lg shadow p-6">
        <h2 className="text-lg font-semibold mb-4">API Connection Status</h2>
        {error && (
          <div className="text-red-600 bg-red-50 p-3 rounded">
            API Error: {error}
          </div>
        )}
        {health && (
          <div className="text-green-600 bg-green-50 p-3 rounded">
            <p>Status: {health.data.status}</p>
            <p className="text-sm text-gray-500 mt-1">
              Service: {health.data.service}
            </p>
          </div>
        )}
        {!health && !error && (
          <div className="text-gray-400">Connecting to API...</div>
        )}
      </div>
    </div>
  );
}

This simple page connects to the Spring Boot health endpoint — a quick sanity check that the frontend-backend connection works.


Docker Compose

Here's the development Docker Compose that wires everything together:

docker-compose.yml:

services:
  postgres:
    image: postgres:17-alpine
    container_name: courses-postgres
    environment:
      POSTGRES_DB: courses_dev
      POSTGRES_USER: courses
      POSTGRES_PASSWORD: courses_dev_password
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U courses -d courses_dev"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  redis:
    image: redis:7-alpine
    container_name: courses-redis
    ports:
      - "6379:6379"
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
 
volumes:
  pgdata:
  redisdata:

Important: We're only containerizing PostgreSQL and Redis — not the Spring Boot API or Next.js frontend. During development, running backend and frontend natively gives us:

  • Faster hot reload — no Docker build step for code changes
  • Easier debugging — attach IDE debugger directly
  • Simpler logs — each process in its own terminal

In Post #15 (Deployment), we'll containerize everything for production.

Start the Services

docker compose up -d

Verify they're running:

docker compose ps
NAME               STATUS          PORTS
courses-postgres   Up (healthy)    0.0.0.0:5432->5432/tcp
courses-redis      Up (healthy)    0.0.0.0:6379->6379/tcp

Test database connection:

docker exec -it courses-postgres psql -U courses -d courses_dev -c "SELECT 1;"

Test Redis connection:

docker exec -it courses-redis redis-cli ping
# PONG

Running the Development Stack

Now we have all the pieces. Here's the complete development workflow:

Step 1: Start Infrastructure

# From the project root
docker compose up -d

Step 2: Run the Spring Boot API

cd backend
./gradlew bootRun

You should see:

Started CoursesApplication in 2.3 seconds

Test the health endpoint:

curl http://localhost:8080/api/health | jq
{
  "success": true,
  "data": {
    "status": "UP",
    "timestamp": "2026-03-22T10:30:00Z",
    "service": "video-courses-api"
  }
}

Step 3: Run the Next.js Frontend

cd frontend
npm run dev

Open http://localhost:3000 in your browser. You should see the home page with "Status: UP" — confirming the frontend is talking to the backend.

Development Architecture

Hot Reload Behavior

LayerTechnologyWhat Happens on Save
FrontendNext.js + TurbopackPage refreshes instantly (~100ms)
BackendSpring Boot DevToolsJVM restarts automatically (~2-3s)
DatabasePostgreSQL (Docker)Persistent — survives restarts
CacheRedis (Docker)Persistent — data kept in Docker volume

Spring Boot DevTools watches for .class file changes. When you save a Java file, your IDE compiles it, DevTools detects the new class, and restarts the application context. The restart uses a "base classloader" trick that skips reloading third-party libraries, keeping restart times under 3 seconds.


Environment Variables

Create .env.example at the project root (committed to git):

# PostgreSQL
POSTGRES_DB=courses_dev
POSTGRES_USER=courses
POSTGRES_PASSWORD=courses_dev_password
DATABASE_URL=jdbc:postgresql://localhost:5432/courses_dev
 
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
 
# Application
SPRING_PROFILES_ACTIVE=dev
NEXT_PUBLIC_API_URL=http://localhost:8080/api

Create .gitignore at the project root:

# Dependencies
backend/build/
frontend/node_modules/
frontend/.next/
 
# Environment
.env
.env.local
backend/src/main/resources/application-local.yml
 
# IDE
.idea/
.vscode/
*.iml
 
# OS
.DS_Store
Thumbs.db
 
# Docker
pgdata/
redisdata/

Testing the Full Stack

Let's verify everything works end-to-end:

1. Health Check via curl

curl -s http://localhost:8080/api/health | jq

Expected output:

{
  "success": true,
  "data": {
    "status": "UP",
    "timestamp": "2026-03-22T10:30:00Z",
    "service": "video-courses-api"
  }
}

2. Frontend Connects to Backend

Open http://localhost:3000 — the home page should show "Status: UP" in a green box.

3. Database Connection

docker exec -it courses-postgres psql -U courses -d courses_dev -c "\dt"

This should return "Did not find any relations" — no tables yet, which is correct. We'll create tables with Flyway migrations in Post #4.

4. Redis Connection

docker exec -it courses-redis redis-cli ping
# PONG
 
docker exec -it courses-redis redis-cli info server | head -5

Complete Request Flow


Common Mistakes

1. PostgreSQL Port Conflict

If port 5432 is already in use (local PostgreSQL running):

# Check what's using port 5432
lsof -i :5432
 
# Option 1: Stop local PostgreSQL
brew services stop postgresql
 
# Option 2: Use a different port in docker-compose.yml
ports:
  - "5433:5432"  # Map to 5433 on host

Don't forget to update application-dev.yml to match the new port.

2. Spring Boot Can't Connect to PostgreSQL

If you see Connection refused errors:

# Check if PostgreSQL container is running
docker compose ps
 
# Check container logs
docker compose logs postgres
 
# Verify the container is healthy
docker inspect courses-postgres --format='{{.State.Health.Status}}'

Make sure Docker Compose started before Spring Boot.

3. CORS Errors in Browser Console

If the frontend can't reach the API:

Access to XMLHttpRequest at 'http://localhost:8080/api/health' from origin
'http://localhost:3000' has been blocked by CORS policy

Check that CorsConfig.java includes http://localhost:3000 in allowedOrigins. Also verify withCredentials: true matches allowCredentials: true.

4. Lombok Not Working

If you see "cannot find symbol" errors for @Data or @Builder:

  • IntelliJ: Install the Lombok plugin (Settings → Plugins → search "Lombok")
  • VS Code: The Java extension pack usually handles it
  • Make sure annotationProcessor 'org.projectlombok:lombok' is in build.gradle

What We've Built

Let's recap what we have after this post:


What's Next?

Our platform runs, but there's no authentication — anyone can access any endpoint. In Post #3, we'll add the complete auth system:

  • Spring Security configuration for JWT
  • Access token + refresh token pattern
  • Email/password registration and login
  • Google and GitHub OAuth2 social login
  • Next.js auth context and protected routes
  • Token refresh interceptor with Axios

The foundation is solid. Time to add security.

Series: Build a Video Streaming Platform
Previous: Series Overview & Architecture
Next: Phase 2: Authentication

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.