Skip to content

Part 4: SPA Runtime Config

Replace build-time API URLs with runtime configuration so the same container image deploys to any environment without rebuilding.

What you'll learn

  • The difference between build-time and runtime configuration
  • Using AppMecca's type: spa and spa_root directives
  • Reading window.__APPMECCA_CONFIG__ in Vue
  • Managing environment variables with mecca var

What you'll need

  • A multi-container deployment from Part 3

The problem with build-time config

In Part 3, the backend URL was injected at build time via VITE_API_URL. This means:

  • Promoting an image from dev to staging requires a full rebuild
  • Every environment gets a different container image
  • Build-time secrets end up baked into the image layers

Build-time leaks

Anything passed as a Docker build argument is visible in the image history. Never pass secrets as build args. Runtime injection solves both the portability and security problems.

How AppMecca SPA runtime config works

When a deploy service has type: spa, AppMecca:

  1. Scans the container's environment variables for keys starting with PUBLIC_
  2. Generates a __appmecca_config.js file containing those values
  3. Writes it into the spa_root directory inside the container
  4. Your HTML loads this script, and Vue reads from window.__APPMECCA_CONFIG__

The container image stays identical across environments. Only the injected config changes.

Update appmecca.yaml

Change the frontend deploy configuration to use type: spa:

database:
  enabled: true

containers:
  - name: backend
    dockerfile: backend/Dockerfile
    context: backend/

  - name: frontend
    dockerfile: frontend/Dockerfile
    context: frontend/

deploy:
  - name: backend
    container: backend
    port: 8080
    path: /api
    health_check:
      path: /health

  - name: frontend
    container: frontend
    type: spa
    command: 'nginx -g "daemon off;"'   # serves the built bundle from a container
    spa_root: /usr/share/nginx/html
    port: 3000
    health_check:
      path: /
    environment:
      PUBLIC_API_URL: "${service:backend}"
      PUBLIC_APP_TITLE: "Todo App"

No more build args

The args block on the frontend container is gone. VITE_API_URL is no longer needed at build time. The PUBLIC_* environment variables are injected at deploy time.

Remove the build arg from the frontend Dockerfile

# frontend/Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
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

The Dockerfile no longer accepts or uses VITE_API_URL.

Load the config script in index.html

Add the script tag to your frontend/index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Todo App</title>
    <script src="/__appmecca_config.js"></script>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

Create a config helper

// frontend/src/config.js

/**
 * Read a runtime config value injected by AppMecca.
 * Falls back to import.meta.env for local development.
 * @param {string} key - Config key (e.g., 'PUBLIC_API_URL')
 * @param {string} fallback - Default value if not found
 * @returns {string}
 */
export function getConfig(key, fallback = '') {
  const runtime = window.__APPMECCA_CONFIG__
  if (runtime && runtime[key] !== undefined) {
    return runtime[key]
  }
  return import.meta.env[`VITE_${key}`] || fallback
}

Local development

The getConfig helper falls back to import.meta.env variables. During local development with npm run dev, create a .env file with VITE_PUBLIC_API_URL=http://localhost:8080 and everything works the same.

Update App.vue

<!-- frontend/src/App.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import { getConfig } from './config.js'

const API_URL = getConfig('PUBLIC_API_URL')
const APP_TITLE = getConfig('PUBLIC_APP_TITLE', 'Todos')
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>{{ APP_TITLE }}</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>

Manage environment variables

Set additional runtime variables without rebuilding. The app name is the first argument, and several KEY=value pairs can go in one command:

mecca var set todo-app PUBLIC_APP_TITLE="My Todos"
mecca var set todo-app PUBLIC_APP_TITLE="My Todos" PUBLIC_SUPPORT_EMAIL="help@example.com"

var set writes to every branch by default

With no -b, the value lands on all of the app's branches. Narrow it explicitly when that is not what you want:

mecca var set todo-app PUBLIC_APP_TITLE="Staging Todos" -b staging

List current variables:

mecca var list todo-app
mecca var list todo-app -b main --dotenv > .env   # .env lines; needs one branch

Remove a variable:

mecca var unset todo-app PUBLIC_SUPPORT_EMAIL
mecca var unset todo-app PUBLIC_SUPPORT_EMAIL -b staging

Redeploy after changes

Environment variable changes take effect on the next deployment, not immediately. After mecca var set, run mecca app deploy todo-app --no-build to apply them without a rebuild.

Build and deploy

mecca build trigger -g git@github.com:yourorg/todo-app.git
mecca app deploy todo-app

The same image now works in dev, staging, and production -- only the PUBLIC_* values change.

Command reference

Command Purpose
mecca var set <app> KEY=value [...] Set variables (all branches unless -b)
mecca var list <app> List variables, grouped by branch
mecca var list <app> -b <branch> --dotenv Emit .env lines for one branch
mecca var unset <app> KEY Remove a variable

What's next

In Part 5: Background Workers, you will add a Spring Task worker process backed by Redis for asynchronous job processing.