Part 3: Going Full-Stack¶
Add a SvelteKit frontend alongside your Express backend using multi-container builds and automatic service discovery.
What you'll learn¶
- Structuring a monorepo with separate backend and frontend directories
- Building two containers from a single repository
- Using
${service:backend}for automatic URL resolution - Serving a SvelteKit SPA with nginx
- Viewing per-service logs
What you'll need¶
- The Express + PostgreSQL app from Part 2 deployed and working
Restructure the repository¶
Move the backend into its own directory and create a frontend alongside it:
todo-app/
backend/
index.js
knexfile.js
entrypoint.sh
migrations/
package.json
Dockerfile
frontend/
src/
static/
package.json
svelte.config.js
vite.config.js
Dockerfile
appmecca.yaml
Move your existing files into backend/. Then scaffold the frontend:
Create the SvelteKit page¶
Replace frontend/src/routes/+page.svelte:
<script>
import { onMount } from "svelte";
const API_URL = import.meta.env.VITE_API_URL || "";
let todos = [];
let newTitle = "";
onMount(async () => {
const res = await fetch(`${API_URL}/api/todos`);
todos = await res.json();
});
async function addTodo() {
const res = await fetch(`${API_URL}/api/todos`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTitle }),
});
const todo = await res.json();
todos = [todo, ...todos];
newTitle = "";
}
async function toggle(todo) {
const res = await fetch(`${API_URL}/api/todos/${todo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...todo, done: !todo.done }),
});
const updated = await res.json();
todos = todos.map((t) => (t.id === updated.id ? updated : t));
}
async function remove(id) {
await fetch(`${API_URL}/api/todos/${id}`, { method: "DELETE" });
todos = todos.filter((t) => t.id !== id);
}
</script>
<h1>Todo App</h1>
<form on:submit|preventDefault={addTodo}>
<input bind:value={newTitle} placeholder="What needs doing?" />
<button type="submit">Add</button>
</form>
<ul>
{#each todos as todo}
<li>
<label>
<input type="checkbox" checked={todo.done} on:change={() => toggle(todo)} />
<span class:done={todo.done}>{todo.title}</span>
</label>
<button on:click={() => remove(todo.id)}>Delete</button>
</li>
{/each}
</ul>
<style>
.done { text-decoration: line-through; opacity: 0.6; }
</style>
Configure SvelteKit as a static SPA¶
Install the static adapter:
Update svelte.config.js:
import adapter from "@sveltejs/adapter-static";
export default {
kit: {
adapter: adapter({ fallback: "index.html" }),
},
};
Frontend Dockerfile¶
Create frontend/Dockerfile:
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
Create frontend/nginx.conf:
server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
Update appmecca.yaml¶
Define both containers and both services:
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: 3000
health_check:
path: /health
- name: frontend
container: frontend
port: 3000
health_check:
path: /
Service URL resolution
${service:backend} resolves at build time to the public HTTPS URL of the backend service, for example https://todo-app-backend-yourgroup.dev.example.com. AppMecca injects it as a Docker build arg.
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¶
Both containers are built, pushed to AppMecca's private image registry, and deployed. Each service gets its own hostname — always including the service name:
https://todo-app-frontend-yourgroup.dev.example.com
https://todo-app-backend-yourgroup.dev.example.com
View per-service logs¶
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
mecca logs todo-app --type container --service backend --since 30m
mecca logs todo-app --level error # errors from every source
Service names
The service name matches the name field under deploy in your appmecca.yaml. Use mecca app show todo-app to see all services, their states and their URLs.
What's next¶
In Part 4: SPA Runtime Config, you will replace the build-time VITE_API_URL with runtime configuration so the same image works across environments without rebuilding.