You want Laravel to handle your API and Vue to handle the user interface.
Keeping them in separate projects gives each application its own dependencies, source code, and build process. But it introduces a few questions: how do the containers communicate? Which address should the browser use? And where should you run npm commands?
In this guide, we will build a working local development setup with a Laravel API, a separate Vue application, and Docker.
What We Are Building
Laravel will return JSON. Vue will request that JSON and display it.
We will use Laravel Sail for the backend and a Node container for the frontend. Both applications share one Docker Compose environment for convenience, while keeping separate source directories and dependencies.
| Application | Source directory | Browser URL |
|---|---|---|
| Laravel API | backend/ | http://localhost:8080 |
| Vue frontend | frontend/ | http://localhost:5173 |
Vue will use Vite’s development proxy to forward requests starting with /api to Laravel.
This is a development setup. Production deployment and authentication require additional configuration.
1. Prepare Your Environment
You need Docker, Docker Compose, Bash, and curl. The commands below work in a Linux terminal, macOS terminal, or a WSL2 Linux terminal on Windows.
Check your installation:
docker --version
docker compose version
docker infoYou do not need PHP, Composer, or Node installed locally for this workflow.
Create a directory for the two projects:
mkdir laravel-vue-docker
cd laravel-vue-dockerAll file paths below are relative to this directory unless stated otherwise.
2. Create the Laravel Backend
Use Laravel’s Docker-based project creation workflow:
curl -sS "https://laravel.build/backend?with=mysql" | bash
cd backendThis downloads and executes the script from Laravel’s official domain. The generated application includes Sail and a MySQL service.
This workflow does not pin a Laravel version. After starting the backend, check the generated version with ./vendor/bin/sail artisan --version.
Open backend/.env and set:
APP_PORT=8080
APP_URL=http://localhost:8080
VITE_PORT=5174Why change VITE_PORT? Sail’s generated application service commonly publishes port 5173 for Laravel’s own Vite server. Our separate Vue container needs that host port, so we move the backend’s unused Vite mapping to 5174.
Start the backend from the backend/ directory:
./vendor/bin/sail up -dWait for MySQL to finish initializing.
3. Add an API Endpoint
From backend/, enable API routing:
./vendor/bin/sail artisan install:api
./vendor/bin/sail artisan migrateThe first command installs Sanctum and creates routes/api.php. This example uses a public endpoint; it does not implement login.
Add the following route to backend/routes/api.php, keeping the existing PHP opening tag, imports, and routes:
Route::get('/greeting', function () {
return response()->json([
'message' => 'Hello from Laravel!',
]);
});The generated file already imports Illuminate\\Support\\Facades\\Route. If that import is absent, add it.
Laravel automatically prefixes routes in this file with /api. Test the endpoint:
curl -i http://localhost:8080/api/greetingYou should receive HTTP 200 and:
{
"message": "Hello from Laravel!"
}You now have a backend that can serve a separate frontend.
4. Create the Vue Frontend with Docker
Return to the parent directory:
cd ..Run Vue’s official scaffolding tool inside a temporary Node container:
docker run --rm -it \
--user "$(id -u):$(id -g)" \
--env HOME=/tmp \
--volume "$PWD:/workspace" \
--workdir /workspace \
node:24-bookworm-slim \
npm create vue@latestIf npm asks to install create-vue, confirm it. Use frontend as the project directory or project name.
For this tutorial, choose JavaScript rather than TypeScript and skip optional features. This keeps the generated configuration consistent with the examples below.
The container writes the Vue project into frontend/ on your computer. The user option helps avoid creating root-owned files.
Laravel and Vue now have their own dependency files:
| Backend | Frontend |
|---|---|
backend/composer.json | frontend/package.json |
backend/routes/api.php | frontend/src/App.vue |
PHP dependencies in vendor/ | JavaScript dependencies in node_modules/ |
5. Add a Frontend Service to Compose
Open the Compose file generated by Sail in backend/, usually compose.yaml. Older versions may use docker-compose.yml.
Add this service inside the existing services section, at the same indentation level as laravel.test and mysql:
frontend:
image: node:24-bookworm-slim
working_dir: /app
user: "${FRONTEND_UID:-1000}:${FRONTEND_GID:-1000}"
environment:
HOME: /tmp
volumes:
- ../frontend:/app
ports:
- "127.0.0.1:5173:5173"
command: npm run dev
networks:
- sailKeep the existing services, networks, and volumes. Do not replace the whole file or create a second top-level services key.
The relative mount ../frontend points to the Vue project next to backend/. The sail network lets Vite reach the Laravel container.
Run these commands to find your user and group IDs:
id -u
id -gAdd the returned numbers to backend/.env. For example, if both commands return 1000:
FRONTEND_UID=1000
FRONTEND_GID=1000Use your actual values so the frontend container writes files as the same user that created the project.
6. Configure Vite to Reach Laravel
Replace frontend/vite.config.js with:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: 'http://laravel.test',
changeOrigin: true,
},
},
},
})The proxy preserves the path. A request to /api/greeting is forwarded to Laravel’s /api/greeting.
The hostname laravel.test is the backend service name in Sail’s Compose file. Vite can resolve it because both containers belong to the sail network.
Notice that we use the backend’s internal port 80, not the host port 8080.
The browser never requests laravel.test directly. It requests the Vue server at localhost:5173, and Vite forwards the API request from inside Docker.
Because the browser uses the same origin for the page and its API request, this example does not require cross-origin CORS configuration.
7. Install Dependencies and Start Vue
From backend/, run:
./vendor/bin/sail run --rm --no-deps frontend npm install
./vendor/bin/sail up -dThe first command installs dependencies in the frontend directory using the Node container. The second starts the environment, including Vue.
Open http://localhost:5173.
You should see the generated Vue application.
8. Display Data from Laravel
Replace frontend/src/App.vue with:
<script setup>
import { onMounted, ref } from 'vue'
const message = ref('')
const loading = ref(false)
const error = ref('')
async function loadGreeting() {
loading.value = true
error.value = ''
message.value = ''
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
try {
const response = await fetch('/api/greeting', {
headers: {
Accept: 'application/json',
},
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
if (typeof data.message !== 'string') {
throw new Error('Unexpected response format')
}
message.value = data.message
} catch (exception) {
error.value =
exception.name === 'AbortError'
? 'The request took too long. Please try again.'
: 'Could not load the message. Please try again.'
} finally {
clearTimeout(timeoutId)
loading.value = false
}
}
onMounted(loadGreeting)
</script>
<template>
<main>
<h1>Laravel + Vue + Docker</h1>
<p v-if="loading">Loading the message...</p>
<p v-else-if="error" role="alert">{{ error }}</p>
<p v-else>{{ message }}</p>
<button :disabled="loading" @click="loadGreeting">
{{ loading ? 'Loading...' : 'Load message' }}
</button>
</main>
</template>Refresh the page. It should display Hello from Laravel!
This small example also handles loading, failed requests, invalid response data, and a five-second timeout. Fetch does not throw automatically for HTTP error responses, so we check response.ok.
The timeout stops the browser from waiting; it does not guarantee that backend processing has stopped.
9. Verify the Connection and Failure Behavior
Check the API through the frontend proxy:
curl -i http://localhost:5173/api/greetingYou should receive the same JSON as the direct backend request.
Now temporarily stop Laravel, from backend/:
./vendor/bin/sail stop laravel.testClick Load message in Vue. You should see a clear error instead of an interface that waits indefinitely.
Start Laravel again:
./vendor/bin/sail up -d laravel.testClick the button again. The message should load successfully.
10. Understand Which Address to Use
Many Docker connection errors come from using the right address in the wrong place.
| Where the request starts | Destination | Address |
|---|---|---|
| Browser | Vue | http://localhost:5173 |
| Browser or host terminal | Laravel | http://localhost:8080 |
| Vite container | Laravel | http://laravel.test:80 |
| Laravel container | MySQL | mysql:3306 |
Inside a container, localhost refers to that container itself. Published host ports are used from your computer; containers on the shared network communicate through service names and internal ports.
Common Problems
Vue does not open
Check the frontend logs:
./vendor/bin/sail logs -f frontendConfirm that dependencies were installed and that Vite listens on 0.0.0.0.
Port 5173 is already in use
Confirm that the backend’s VITE_PORT is set to 5174, then recreate the containers with ./vendor/bin/sail up -d.
If another application owns port 5173, stop it or change the frontend’s host mapping, for example to 127.0.0.1:5175:5173. Then open localhost:5175.
The proxy cannot reach Laravel
Check that both services use the sail network, the backend service is named laravel.test, and Laravel is running.
API requests return 404
Check that install:api completed and the greeting route is in routes/api.php. Inspect registered routes:
./vendor/bin/sail artisan route:list --path=apiFile changes do not appear
On Windows, keep the project inside the WSL2 Linux filesystem and use an editor connected to WSL.
If filesystem events still do not work, add watch: { usePolling: true } inside Vite’s server configuration and restart the frontend. Polling can increase CPU usage, so enable it only when needed.
Development Commands
Run these from backend/:
| Task | Command |
|---|---|
| Start the environment | ./vendor/bin/sail up -d |
| Run backend tests | ./vendor/bin/sail artisan test |
| Install frontend dependencies | ./vendor/bin/sail run --rm --no-deps frontend npm install |
| Build the frontend | ./vendor/bin/sail run --rm --no-deps frontend npm run build |
| Follow frontend logs | ./vendor/bin/sail logs -f frontend |
| Stop and remove containers | ./vendor/bin/sail down |
The frontend build produces frontend/dist/. Building it does not deploy it.
What Changes in Production?
Vite’s development proxy exists only while the development server runs.
For production, serve the compiled Vue application and configure a reverse proxy to forward /api to Laravel. Alternatively, serve the API on a separate origin and configure explicit CORS rules and an appropriate frontend API URL.
Authentication also needs its own design. Installing Sanctum alone does not implement SPA login, session handling, or CSRF protection.
Separate Applications, a Working Connection
You now have a Laravel backend and a Vue frontend with separate dependencies and source code, connected through a shared Docker development environment.
This structure is useful when your frontend needs its own build process or your API serves multiple clients. It also adds responsibility: define your API responses clearly, handle failures, and test the connection between the applications.
Your next step: add an endpoint that returns real application data and build a Vue screen that handles both success and failure.
References:
Validation note: the examples were checked against the official documentation, but the complete container setup was not executed in the authoring environment.