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:
React (Vite) → Nginx → PHP-FPM (Laravel) → MySQL
:5173 :8000 :9000 :3306Estructura final esperada
Partimos de:
~/laravel-docker/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── resources/
├── routes/
├── storage/
├── tests/
├── vendor/
├── docker-compose/
│ └── nginx/
│ └── default.conf
├── .env
├── composer.json
├── Dockerfile
└── docker-compose.ymlY terminaremos con:
~/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:
~/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:
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:
| Servicio | Imagen base | Necesita |
|---|---|---|
app | php:8.3-fpm | PHP, Composer, extensiones PHP |
frontend | node:20-alpine | Node, 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 ú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 ReactY en docker-compose.yml:
services:
app:
build:
context: .
target: laravel
frontend:
build:
context: .
target: reactPero no es lo recomendado para este caso, porque:
Los contextos de build son diferentes (React solo necesita
./frontend, no todo Laravel).Es más difícil de mantener.
Pierdes la caché de Docker por separado.
Mezclas responsabilidades.
Resumen
Desarrollo: dos
Dockerfileseparados (uno en raíz, otro enfrontend/).Producción: podrías tener aún más, por ejemplo
Dockerfile.prodpara 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.
~/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
# 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: DockerfileCada 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)
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:
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 testsSe combinan así:
# 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 -dEsto 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
| Pregunta | Respuesta |
|---|---|
¿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.ymlno "contiene" los Dockerfiles, solo los referencia conbuild.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:
docker exec -it laravel-app-1 /bin/bashEdita routes/api.php:
nano routes/api.phpContenido:
<?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:
exit1.2 Verificar que la API responde
Desde el host:
curl http://localhost:8000/api/pingDeberías obtener:
{"status":"ok","message":"API Laravel funcionando","timestamp":"..."}Si obtienes un error 404, ejecuta dentro del contenedor:
docker exec -it laravel-app-1 php artisan route:list --path=apiPaso 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:
docker exec -it laravel-app-1 /bin/bash
nano config/cors.phpContenido:
<?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
docker exec -it laravel-app-1 php artisan config:clear
docker exec -it laravel-app-1 php artisan cache:clearSal del contenedor:
exitVerifica de nuevo desde el host:
curl -H "Origin: http://localhost:5173" -i http://localhost:8000/api/pingDebes ver en los headers:
Access-Control-Allow-Origin: http://localhost:5173Paso 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:
cd ~/laravel-docker
npm create vite@latest frontend -- --template react
cd frontend
npm installOpción B: Contenedor Node temporal (si no tienes Node)
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:
~/laravel-docker/frontend/
├── public/
├── src/
│ ├── App.jsx
│ ├── main.jsx
│ └── ...
├── index.html
├── package.json
└── vite.config.jsPaso 4: Crear el Dockerfile para React (modo desarrollo)
Dentro de ~/laravel-docker/frontend/, crea un Dockerfile:
nano DockerfileContenido:
# 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
/appcon 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:
nano frontend/vite.config.jsContenido:
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/pingdesde React y Vite lo redirige internamente a Nginx. Esto evita CORS por completo. Aun así, dejamos CORS configurado por si prefieres llamar directo ahttp://localhost:8000.
Paso 6: Actualizar docker-compose.yml
Añadimos el servicio frontend al stack. Abre el archivo:
nano docker-compose.ymlContenido completo:
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 enhttp://localhost:5173.volumes:./frontend:/app: bind mount para HMR./app/node_modules: volumen anónimo que preservanode_modulesdentro 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:
docker compose up -dVerifica que los cuatro contenedores estén arriba:
docker psDeberías ver:
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-1Abre 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
docker exec -it laravel-frontend-1 npm install axiosO desde el host (si tienes Node):
cd frontend && npm install axios8.2 Crear un cliente API
Dentro de frontend/src/, crea api.js:
nano frontend/src/api.jsContenido:
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
nano frontend/src/App.jsxContenido:
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:
docker exec -it laravel-app-1 php artisan migrate --seedSi no tienes seeders, crea uno rápido:
docker exec -it laravel-app-1 php artisan tinker\App\Models\User::factory(5)->create();
exit8.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
Edita
frontend/src/App.jsxen el host y cambia el<h1>.Guarda. Vite recargará el navegador automáticamente.
Edita
routes/api.phpen el host, añade un nuevo endpoint.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
# 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 -vSolución de problemas comunes
El navegador muestra "Connection refused" en :5173
Verifica que Vite esté escuchando en 0.0.0.0:
docker compose logs frontendDebes ver algo como:
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:
volumes:
- ./frontend:/app
- /app/node_modulesY reconstruye:
docker compose down
docker compose up -d --build frontendError CORS en el navegador
Access to XMLHttpRequest at 'http://localhost:8000/api/ping' from origin 'http://localhost:5173' has been blocked by CORS policySoluciones:
Usa el proxy de Vite (recomendado): llama
/api/pingen lugar dehttp://localhost:8000/api/ping.Revisa
config/cors.phpy ejecutaphp artisan config:clear.Asegúrate de que
supports_credentials: trueywithCredentials: trueestén alineados.
HMR no detecta cambios (Windows/WSL)
Asegúrate de tener:
watch: { usePolling: true }Y en docker-compose.yml:
environment:
- CHOKIDAR_USEPOLLING=truePuerto 5173 ocupado
Cambia el mapeo en docker-compose.yml:
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:
docker exec -it laravel-frontend-1 npm installConclusión
Has extendido tu entorno Docker con un cuarto servicio:
| Servicio | Imagen | Puerto host | Función |
|---|---|---|---|
app | laravel-app | — | PHP-FPM con Laravel |
nginx | nginx:alpine | 8000 | Sirve Laravel y API |
db | mysql:8 | — | Base de datos |
frontend | laravel-frontend | 5173 | React + 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.
Comentarios
Publicar un comentario