Part 3: Going Full-Stack¶
Add a Vue 3 frontend to your Spring Boot API with multi-container builds and inter-service URL resolution.
What you'll learn¶
- Building a Vue 3 SPA with Vite
- Configuring multi-container builds in
appmecca.yaml - Using
${service:backend}for automatic backend URL injection - Serving the frontend with nginx
What you'll need¶
- A working todo API from Part 2
- Node.js 20+ installed locally (for frontend development only)
Project structure¶
Reorganize your repository into backend and frontend directories:
todo-app/
backend/
src/
build.gradle
settings.gradle
Dockerfile
frontend/
src/
package.json
vite.config.js
Dockerfile
appmecca.yaml
Move your existing Spring Boot code into backend/. The backend Dockerfile stays the same -- just update its context path in appmecca.yaml.
Create the Vue 3 frontend¶
Initialize the project:
Write the main App component¶
<!-- frontend/src/App.vue -->
<script setup>
import { ref, onMounted } from 'vue'
const API_URL = import.meta.env.VITE_API_URL || ''
const todos = ref([])
const newTitle = ref('')
async function fetchTodos() {
const res = await fetch(`${API_URL}/api/todos`)
todos.value = await res.json()
}
async function addTodo() {
if (!newTitle.value.trim()) return
await fetch(`${API_URL}/api/todos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle.value })
})
newTitle.value = ''
await fetchTodos()
}
async function toggleTodo(todo) {
await fetch(`${API_URL}/api/todos/${todo.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...todo, done: !todo.done })
})
await fetchTodos()
}
async function deleteTodo(id) {
await fetch(`${API_URL}/api/todos/${id}`, { method: 'DELETE' })
await fetchTodos()
}
onMounted(fetchTodos)
</script>
<template>
<div class="container">
<h1>Todo App</h1>
<form @submit.prevent="addTodo">
<input v-model="newTitle" placeholder="What needs doing?" />
<button type="submit">Add</button>
</form>
<ul>
<li v-for="todo in todos" :key="todo.id">
<input type="checkbox" :checked="todo.done" @change="toggleTodo(todo)" />
<span :class="{ done: todo.done }">{{ todo.title }}</span>
<button @click="deleteTodo(todo.id)">Delete</button>
</li>
</ul>
</div>
</template>
<style>
.done { text-decoration: line-through; opacity: 0.6; }
</style>
Write the frontend Dockerfile¶
Build the Vue app with Node, then serve the static files with nginx:
# frontend/Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
Add the nginx config¶
# frontend/nginx.conf
server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
SPA routing
The try_files directive ensures that client-side routes like /about fall back to index.html instead of returning a 404. This is essential for any single-page application.
Update appmecca.yaml¶
Configure both containers and use ${service:backend} to inject the backend URL at build time:
database:
enabled: true
containers:
- name: backend
dockerfile: backend/Dockerfile
context: backend/
- name: frontend
dockerfile: frontend/Dockerfile
context: frontend/
args:
VITE_API_URL: "${service:backend}"
deploy:
- name: backend
container: backend
port: 8080
health_check:
path: /health
- name: frontend
container: frontend
port: 3000
health_check:
path: /
Service URL resolution
${service:backend} resolves to the public HTTPS URL of the backend service, for example https://todo-app-backend-yourgroup.dev.example.com. AppMecca resolves this at build time and passes it as the VITE_API_URL build argument.
A typo is caught early — the validator rejects a ${service:...} reference to a service that is not declared in the same deploy: block.
Build and deploy¶
AppMecca builds both containers in the same build run, pushes both to its private image registry, then deploys them together. Each service gets its own hostname on the platform HTTPS router.
Verify the deployment¶
Check that both services are running:
View service logs¶
Read the container output for a specific service. The service is selected with --service, not as a positional argument:
mecca logs todo-app --type container --service frontend
mecca logs todo-app --type container --service backend
Use --since to widen or narrow the time window (the default is the last hour), and --level to filter:
mecca logs todo-app --type container --service backend --since 30m
mecca logs todo-app --level error
mecca logs todo-app --search "timeout"
Test the full stack¶
Open the frontend URL from mecca app show todo-app. You should see the todo app with a form to add items. Add a few todos and verify they persist across page reloads -- they are stored in PostgreSQL via the backend API.
How multi-container deployment works¶
When AppMecca deploys a multi-container app:
- It provisions a route on the platform HTTPS router for each service that declares a
port - Each service gets its own URL — always including the service name
- The containers are pulled and started on AppMecca hosts
- Health checks confirm each service is responding before traffic is switched to it
- The deployment status moves to
completed
https://todo-app-frontend-yourgroup.dev.example.com --> nginx (port 3000)
https://todo-app-backend-yourgroup.dev.example.com --> Spring Boot (port 8080)
Command reference¶
| Command | Purpose |
|---|---|
mecca app show <app> |
List all services with status and URLs |
mecca logs <app> --type container --service <svc> |
Container output for one service |
mecca logs <app> --since 30m |
Logs from a time window |
mecca logs <app> --level error |
Errors only, across every source |
What's next¶
In Part 4: SPA Runtime Config, you will replace the build-time API URL with runtime configuration, so the same container image works in any environment without rebuilding.