Configurar un contenedor React para consumir la API de Laravel

 

Configurar un contenedor React para consumir la API de Laravel

Este tutorial es la continuación directa del tutorial anterior. Ahora agregaremos un cuarto servicio a nuestro docker-compose.yml: un contenedor con React (Vite) que consumirá la API REST de Laravel. Al final tendrás un stack completo:

text
React (Vite)  →  Nginx  →  PHP-FPM (Laravel)  →  MySQL
   :5173          :8000        :9000              :3306

Estructura final esperada

Partimos de:

text
~/laravel-docker/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── resources/
├── routes/
├── storage/
├── tests/
├── vendor/
├── docker-compose/
│   └── nginx/
│       └── default.conf
├── .env
├── composer.json
├── Dockerfile
└── docker-compose.yml

Y terminaremos con:

text
~/laravel-docker/
├── frontend/                    <-- NUEVO
│   ├── src/
│   ├── public/
│   ├── package.json
│   ├── vite.config.js
│   └── Dockerfile               <-- NUEVO
├── ... (resto igual)
└── docker-compose.yml           <-- MODIFICADO

 habría dos Dockerfile, uno por cada servicio que necesita una imagen personalizada:

text
~/laravel-docker/
├── Dockerfile                  ← Para Laravel (PHP-FPM)
├── docker-compose.yml
├── frontend/
│   ├── Dockerfile              ← Para React (Node/Vite)
│   ├── package.json
│   ├── vite.config.js
│   └── src/
└── ...

No hay conflicto porque están en directorios distintos y cada servicio en docker-compose.yml usa su propio contexto de construcción:

yaml
services:
  app:
    build:
      context: .                # raíz del proyecto
      dockerfile: Dockerfile    # ./Dockerfile  (Laravel)

  frontend:
    build:
      context: ./frontend       # carpeta frontend
      dockerfile: Dockerfile    # ./frontend/Dockerfile  (React)

¿Por qué dos?

Porque son tecnologías diferentes:

ServicioImagen baseNecesita
appphp:8.3-fpmPHP, Composer, extensiones PHP
frontendnode:20-alpineNode, npm, Vite, React

Cada uno tiene sus propias dependencias y comandos de arranque. Mezclarlos en un solo Dockerfile sería complicado y poco eficiente.

¿Podría usar un solo Dockerfile?

Sí, técnicamente se puede con multi-stage builds o con múltiples targets:

dockerfile
# Dockerfile único en la raíz
FROM php:8.3-fpm AS laravel
# ... configuración de Laravel

FROM node:20-alpine AS react
# ... configuración de React

Y en docker-compose.yml:

yaml
services:
  app:
    build:
      context: .
      target: laravel
  frontend:
    build:
      context: .
      target: react

Pero no es lo recomendado para este caso, porque:

  1. Los contextos de build son diferentes (React solo necesita ./frontend, no todo Laravel).

  2. Es más difícil de mantener.

  3. Pierdes la caché de Docker por separado.

  4. Mezclas responsabilidades.

Resumen

  • Desarrollo: dos Dockerfile separados (uno en raíz, otro en frontend/).

  • Producción: podrías tener aún más, por ejemplo Dockerfile.prod para el frontend con Nginx sirviendo los estáticos.

  • La clave es que cada servicio tenga su propia imagen y su propio build.context.



docker-compose.yml

Un solo docker-compose.yml en la raíz del proyecto orquesta todos los servicios: app, nginx, db y frontend. Ese archivo es el punto de entrada único para levantar todo el stack con un solo comando.

text
~/laravel-docker/
├── Dockerfile                  ← Imagen de Laravel
├── docker-compose.yml          ← Orquesta TODOS los servicios
├── docker-compose/
│   └── nginx/
│       └── default.conf
├── frontend/
│   ├── Dockerfile              ← Imagen de React
│   └── ...
└── ... (código Laravel)

Cómo se relacionan

yaml
# docker-compose.yml  (uno solo, en la raíz)
services:
  app:                          # ← usa ./Dockerfile
    build:
      context: .
      dockerfile: Dockerfile

  nginx:                        # ← usa imagen oficial, sin Dockerfile
    image: nginx:alpine

  db:                           # ← usa imagen oficial, sin Dockerfile
    image: mysql:8

  frontend:                     # ← usa ./frontend/Dockerfile
    build:
      context: ./frontend
      dockerfile: Dockerfile

Cada servicio apunta a su propio Dockerfile (o a una imagen oficial). Pero el docker-compose.yml es uno solo.

Comandos (todos desde la raíz)

bash
cd ~/laravel-docker

docker compose up -d                    # levanta los 4 servicios
docker compose up -d --build            # reconstruye imágenes y levanta
docker compose up -d --build frontend   # reconstruye solo el frontend
docker compose logs -f frontend         # logs solo del frontend
docker compose down                     # detiene y elimina todo
docker compose ps                       # estado de los servicios

¿Cuándo tendrías varios docker-compose.yml?

En proyectos grandes a veces se usan múltiples archivos que se combinan, por ejemplo:

text
docker-compose.yml              # base común
docker-compose.override.yml     # overrides para desarrollo (se aplica automático)
docker-compose.prod.yml         # overrides para producción
docker-compose.test.yml         # para tests

Se combinan así:

bash
# Desarrollo (usa docker-compose.yml + docker-compose.override.yml automáticamente)
docker compose up -d

# Producción
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# Tests
docker compose -f docker-compose.yml -f docker-compose.test.yml up -d

Esto es útil cuando quieres, por ejemplo:

  • En dev: bind mounts + HMR + puertos expuestos.

  • En prod: imágenes preconstruidas, sin bind mounts, con Nginx sirviendo estáticos.

Pero para el tutorial, un solo docker-compose.yml es suficiente y correcto.


Regla mental para recordar

PreguntaRespuesta
¿Cuántos Dockerfile?Uno por imagen personalizada.
¿Cuántos docker-compose.yml?Uno por entorno (dev, prod…).
¿Dónde va el docker-compose.yml?En la raíz del proyecto.
¿Dónde van los Dockerfile?En el contexto de cada servicio.

Resumen

  • Dockerfile: varios (uno por servicio con imagen propia).

  • docker-compose.yml: uno solo para todo el stack (o varios solo si separas entornos).

  • El docker-compose.yml no "contiene" los Dockerfiles, solo los referencia con build.context + build.dockerfile.

Así que sí: un único docker-compose.yml orquesta los 4 contenedores, pero cada uno tiene su propio Dockerfile (o imagen oficial).



Paso 1: Exponer una API en Laravel (backend)

Antes de crear el frontend, necesitamos que Laravel exponga endpoints consumibles. Laravel 11 ya incluye routes/api.php y el middleware de API listo.

1.1 Crear un endpoint de prueba

Si aún no tienes un endpoint, crea uno rápido. Dentro del contenedor:

bash
docker exec -it laravel-app-1 /bin/bash

Edita routes/api.php:

bash
nano routes/api.php

Contenido:

php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Models\User;

Route::get('/ping', function () {
    return response()->json([
        'status' => 'ok',
        'message' => 'API Laravel funcionando',
        'timestamp' => now()->toDateTimeString(),
    ]);
});

// Endpoint que devuelve usuarios (requiere tabla users)
Route::get('/users', function () {
    return User::select('id', 'name', 'email', 'created_at')->get();
});

Sal del contenedor:

bash
exit

1.2 Verificar que la API responde

Desde el host:

bash
curl http://localhost:8000/api/ping

Deberías obtener:

json
{"status":"ok","message":"API Laravel funcionando","timestamp":"..."}

Si obtienes un error 404, ejecuta dentro del contenedor:

bash
docker exec -it laravel-app-1 php artisan route:list --path=api

Paso 2: Habilitar CORS en Laravel

React se ejecutará en http://localhost:5173 y Laravel en http://localhost:8000. Son orígenes distintos, así que el navegador bloqueará las peticiones sin CORS configurado.

2.1 Editar config/cors.php

En Laravel 11 este archivo puede no existir; créalo si es necesario:

bash
docker exec -it laravel-app-1 /bin/bash
nano config/cors.php

Contenido:

php
<?php

return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['*'],
    'allowed_origins' => ['http://localhost:5173'],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 0,
    'supports_credentials' => true,
];

2.2 Limpiar cachés

bash
docker exec -it laravel-app-1 php artisan config:clear
docker exec -it laravel-app-1 php artisan cache:clear

Sal del contenedor:

bash
exit

Verifica de nuevo desde el host:

bash
curl -H "Origin: http://localhost:5173" -i http://localhost:8000/api/ping

Debes ver en los headers:

text
Access-Control-Allow-Origin: http://localhost:5173

Paso 3: Crear la aplicación React

Vamos a crear el proyecto React dentro del contenedor app (aprovechando que tiene Node no instalado… espera, no lo tiene). Mejor lo hacemos directamente en el host si tienes Node instalado, o creamos un contenedor Node temporal.

Opción A (recomendada): Node en el host

Si tienes Node 20+ instalado:

bash
cd ~/laravel-docker
npm create vite@latest frontend -- --template react
cd frontend
npm install

Opción B: Contenedor Node temporal (si no tienes Node)

bash
cd ~/laravel-docker
docker run --rm -it -v "$PWD":/app -w /app node:20-alpine \
  sh -c "npm create vite@latest frontend -- --template react && cd frontend && npm install"

Al final tendrás:

text
~/laravel-docker/frontend/
├── public/
├── src/
│   ├── App.jsx
│   ├── main.jsx
│   └── ...
├── index.html
├── package.json
└── vite.config.js

Paso 4: Crear el Dockerfile para React (modo desarrollo)

Dentro de ~/laravel-docker/frontend/, crea un Dockerfile:

bash
nano Dockerfile

Contenido:

dockerfile
# 1. Imagen base: Node 20 en Alpine (ligera)
FROM node:20-alpine

# 2. Directorio de trabajo dentro del contenedor
WORKDIR /app

# 3. Copiamos package.json y package-lock.json primero
#    para aprovechar la caché de Docker en instalaciones
COPY package*.json ./

# 4. Instalamos dependencias
RUN npm install

# 5. Copiamos el resto del código (en dev se sobreescribe con el bind mount)
COPY . .

# 6. Exponemos el puerto por defecto de Vite
EXPOSE 5173

# 7. Comando de arranque: servidor de desarrollo accesible desde fuera
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]

Explicación

  • node:20-alpine: imagen oficial ligera.

  • --host 0.0.0.0: obligatorio para que Vite escuche fuera del contenedor.

  • --port 5173: puerto por defecto de Vite.

  • En desarrollo sobreescribiremos /app con un bind mount, así que los cambios en el host se reflejan al instante (HMR de Vite).


Paso 5: Configurar Vite para HMR y proxy hacia la API

Edita frontend/vite.config.js:

bash
nano frontend/vite.config.js

Contenido:

js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    host: '0.0.0.0',
    port: 5173,
    strictPort: true,
    // HMR a través del navegador (necesario cuando se corre en Docker)
    hmr: {
      host: 'localhost',
      port: 5173,
      protocol: 'ws',
    },
    watch: {
      // Necesario en algunos sistemas (Windows/WSL) para detectar cambios
      usePolling: true,
    },
    // Proxy opcional: permite llamar /api/... sin CORS
    proxy: {
      '/api': {
        target: 'http://nginx:80',
        changeOrigin: true,
      },
    },
  },
})

Nota sobre el proxy: con el proxy puedes llamar /api/ping desde React y Vite lo redirige internamente a Nginx. Esto evita CORS por completo. Aun así, dejamos CORS configurado por si prefieres llamar directo a http://localhost:8000.


Paso 6: Actualizar docker-compose.yml

Añadimos el servicio frontend al stack. Abre el archivo:

bash
nano docker-compose.yml

Contenido completo:

yaml
version: '3'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        user: ${USER:-udemy}
        uid: ${UID:-1000}
    image: laravel-app
    container_name: laravel-app-1
    restart: unless-stopped
    working_dir: /var/www
    volumes:
      - ./:/var/www
    networks:
      - laravel

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "8000:80"
    volumes:
      - ./:/var/www
      - ./docker-compose/nginx:/etc/nginx/conf.d
    networks:
      - laravel
    depends_on:
      - app

  db:
    image: mysql:8
    restart: unless-stopped
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: yes
      MYSQL_DATABASE: ${DB_DATABASE}
    networks:
      - laravel
    volumes:
      - db_data:/var/lib/mysql

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    image: laravel-frontend
    container_name: laravel-frontend-1
    restart: unless-stopped
    working_dir: /app
    ports:
      - "5173:5173"
    volumes:
      - ./frontend:/app
      - /app/node_modules   # <-- volumen anónimo: evita que el bind mount borre node_modules
    environment:
      - CHOKIDAR_USEPOLLING=true
    networks:
      - laravel
    depends_on:
      - nginx

networks:
  laravel:
    driver: bridge

volumes:
  db_data:

Puntos clave del nuevo servicio

  • ports: "5173:5173": accesible desde el host en http://localhost:5173.

  • volumes:

    • ./frontend:/app: bind mount para HMR.

    • /app/node_modules: volumen anónimo que preserva node_modules dentro del contenedor. Sin esto, el bind mount "vaciaría" esa carpeta en el contenedor porque el host no la tiene.

  • depends_on: nginx: garantiza que Nginx arranque antes.


Paso 7: Levantar todo el stack

Desde ~/laravel-docker:

bash
docker compose up -d

Verifica que los cuatro contenedores estén arriba:

bash
docker ps

Deberías ver:

text
CONTAINER ID   IMAGE             ...   PORTS                    NAMES
xxxx           laravel-frontend  ...   0.0.0.0:5173->5173/tcp   laravel-frontend-1
xxxx           nginx:alpine      ...   0.0.0.0:8000->80/tcp     laravel-docker-nginx-1
xxxx           laravel-app       ...                            laravel-app-1
xxxx           mysql:8           ...                            laravel-docker-db-1

Abre http://localhost:5173 y verás la página por defecto de Vite + React.


Paso 8: Consumir la API desde React

8.1 Instalar axios

bash
docker exec -it laravel-frontend-1 npm install axios

O desde el host (si tienes Node):

bash
cd frontend && npm install axios

8.2 Crear un cliente API

Dentro de frontend/src/, crea api.js:

bash
nano frontend/src/api.js

Contenido:

js
import axios from 'axios';

// Usamos el proxy de Vite: /api -> http://nginx:80/api
// Si prefieres llamar directo a Laravel, cambia a 'http://localhost:8000/api'
const api = axios.create({
  baseURL: '/api',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  },
  withCredentials: true,
});

export default api;

8.3 Modificar App.jsx

bash
nano frontend/src/App.jsx

Contenido:

jsx
import { useEffect, useState } from 'react';
import api from './api';
import './App.css';

function App() {
  const [ping, setPing] = useState(null);
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const pingRes = await api.get('/ping');
        setPing(pingRes.data);

        const usersRes = await api.get('/users');
        setUsers(usersRes.data);
      } catch (err) {
        console.error(err);
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, []);

  return (
    <div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
      <h1>React + Laravel API</h1>

      {loading && <p>Cargando...</p>}
      {error && <p style={{ color: 'red' }}>Error: {error}</p>}

      {ping && (
        <div style={{ background: '#f0f0f0', padding: '1rem', borderRadius: 8 }}>
          <h2>Estado de la API</h2>
          <pre>{JSON.stringify(ping, null, 2)}</pre>
        </div>
      )}

      {users.length > 0 && (
        <>
          <h2>Usuarios ({users.length})</h2>
          <ul>
            {users.map((u) => (
              <li key={u.id}>
                <strong>{u.name}</strong> — {u.email}
              </li>
            ))}
          </ul>
        </>
      )}

      {!loading && users.length === 0 && !error && (
        <p>No hay usuarios. Ejecuta <code>php artisan migrate --seed</code>.</p>
      )}
    </div>
  );
}

export default App;

8.4 Poblar la base de datos (opcional)

Si no tienes usuarios, créalos:

bash
docker exec -it laravel-app-1 php artisan migrate --seed

Si no tienes seeders, crea uno rápido:

bash
docker exec -it laravel-app-1 php artisan tinker
php
\App\Models\User::factory(5)->create();
exit

8.5 Ver el resultado

Vite detectará los cambios automáticamente (HMR). Recarga http://localhost:5173 y verás:

  • El JSON de /api/ping.

  • La lista de usuarios traída desde MySQL a través de Laravel.


Paso 9: Probar la sincronización en tiempo real

  1. Edita frontend/src/App.jsx en el host y cambia el <h1>.

  2. Guarda. Vite recargará el navegador automáticamente.

  3. Edita routes/api.php en el host, añade un nuevo endpoint.

  4. Recarga el navegador (Laravel no tiene HMR, pero sí lee los cambios al instante).

Esto confirma que los bind mounts funcionan en ambos sentidos.


Paso 10: Comandos útiles con el nuevo servicio

bash
# Ver logs del frontend
docker compose logs -f frontend

# Entrar al contenedor de React
docker exec -it laravel-frontend-1 sh

# Instalar una dependencia npm
docker exec -it laravel-frontend-1 npm install react-router-dom

# Reconstruir solo el frontend (tras cambiar el Dockerfile)
docker compose up -d --build frontend

# Detener todo
docker compose down

# Detener y borrar volúmenes (incluye MySQL)
docker compose down -v

Solución de problemas comunes

El navegador muestra "Connection refused" en :5173

Verifica que Vite esté escuchando en 0.0.0.0:

bash
docker compose logs frontend

Debes ver algo como:

text
VITE v5.x.x  ready in XXX ms
➜  Local:   http://localhost:5173/
➜  Network: http://172.x.x.x:5173/

Si solo aparece localhost, revisa vite.config.js (debe tener host: '0.0.0.0').

node_modules está vacío o da error de módulos faltantes

Ocurre cuando el bind mount sobrescribe node_modules. Asegúrate de tener en docker-compose.yml:

yaml
volumes:
  - ./frontend:/app
  - /app/node_modules

Y reconstruye:

bash
docker compose down
docker compose up -d --build frontend

Error CORS en el navegador

text
Access to XMLHttpRequest at 'http://localhost:8000/api/ping' from origin 'http://localhost:5173' has been blocked by CORS policy

Soluciones:

  1. Usa el proxy de Vite (recomendado): llama /api/ping en lugar de http://localhost:8000/api/ping.

  2. Revisa config/cors.php y ejecuta php artisan config:clear.

  3. Asegúrate de que supports_credentials: true y withCredentials: true estén alineados.

HMR no detecta cambios (Windows/WSL)

Asegúrate de tener:

js
watch: { usePolling: true }

Y en docker-compose.yml:

yaml
environment:
  - CHOKIDAR_USEPOLLING=true

Puerto 5173 ocupado

Cambia el mapeo en docker-compose.yml:

yaml
ports:
  - "5174:5173"

Y en vite.config.js mantén port: 5173 (el interno).

docker compose up falla al construir el frontend

Si el package-lock.json no existe, npm install funcionará igual, pero es más lento. Para generarlo:

bash
docker exec -it laravel-frontend-1 npm install

Conclusión

Has extendido tu entorno Docker con un cuarto servicio:

ServicioImagenPuerto hostFunción
applaravel-app—PHP-FPM con Laravel
nginxnginx:alpine8000Sirve Laravel y API
dbmysql:8—Base de datos
frontendlaravel-frontend5173React + Vite (dev con HMR)

Ahora tienes un stack full-stack completamente dockerizado:

  • Backend: Laravel + MySQL sirviendo una API REST en http://localhost:8000/api.

  • Frontend: React + Vite consumiendo esa API en http://localhost:5173.

  • Sincronización en vivo: editas en tu host, ves los cambios al instante en ambos contenedores.

  • Aislamiento: todo reproducible con un solo docker compose up -d.

Siguientes pasos sugeridos

Comentarios

Entradas más populares de este blog

Tutorial CRUD con Laravel 12-Pastel::factory(50)->create();

CRUD Completo con Laravel 12 - Guía Práctica Paso a Paso-productos

CRUD en Laravel 12 con Git - Guía para Principiantes