Part 7: Internal Service Discovery¶
Add an auth service that the backend calls server-to-server, and understand how AppMecca services address each other.
Use ${service:name} — the :internal form is currently unreliable
An internal URL carries the host port the target service happened to be using when your container started. That port changes every time the target service is redeployed (a new container is brought up on a temporary port before the old one is removed), so the URL stops working the moment the service you are calling is next deployed. Redeploying the calling service fixes it — until the next time.
Recommendation for this tutorial and for production: use the plain
${service:name} form. It resolves to the service's public HTTPS URL,
which is stable across redeploys. The extra hop through AppMecca's HTTPS
router is a good trade for a URL that does not silently break.
If you want the internal path anyway, declare both and fall back in
code: an app that holds only the :internal form will get
connection-refused after the callee's next deploy.
Tracked as a platform bug; a proper fix needs a stable internal address that survives a redeploy.
What you'll learn¶
- Adding a supporting microservice
- The difference between the public and internal URL templates
- Why
${service:name}is the form to reach for - What
visibilityactually does — and why it is an app-wide setting
What you'll need¶
- The full-stack app from Part 6 deployed and working
Public vs internal URLs¶
AppMecca provides two URL template types:
| Template | Resolves to | Notes |
|---|---|---|
${service:backend} |
https://todo-app-backend-yourgroup.dev.example.com |
Routed by the AppMecca HTTPS router. Stable across redeploys — prefer this. |
${service:auth:internal} |
http://auth.todo-app.dev.appmecca.internal:8103 |
Resolves only on AppMecca's private network. The port is the allocated host port, not the container port, and it changes on every redeploy. |
Internal names are served by AppMecca's private DNS and skip the HTTPS router entirely, so there is no TLS handshake — but see the warning above: the port they carry is not stable.
Private DNS
AppMecca maintains a private DNS zone (*.appmecca.internal) for every environment. Internal service records are registered automatically during deployment, pointing at the host each container is running on — which is why the port that goes with the name is the host port, not the container port you declared.
Create the auth service¶
Create auth-service/ at the repository root alongside backend/ and frontend/:
Initialise the auth service:
Create auth-service/index.js:
const express = require("express");
const jwt = require("jsonwebtoken");
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3100;
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret";
app.get("/health", (_req, res) => res.json({ status: "healthy" }));
app.post("/auth/validate", (req, res) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ valid: false, error: "No token" });
try {
const decoded = jwt.verify(token, JWT_SECRET);
res.json({ valid: true, user: decoded });
} catch {
res.status(401).json({ valid: false, error: "Invalid token" });
}
});
app.post("/auth/token", (req, res) => {
const { username } = req.body;
if (!username) return res.status(400).json({ error: "Username required" });
const token = jwt.sign({ username, iat: Date.now() }, JWT_SECRET, {
expiresIn: "24h",
});
res.json({ token });
});
app.listen(PORT, () => console.log(`Auth service listening on ${PORT}`));
Create auth-service/Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3100
CMD ["node", "index.js"]
Call the auth service from the backend¶
Update backend/index.js to validate tokens via the internal auth service:
const AUTH_URL = process.env.AUTH_SERVICE_URL || "http://localhost:3100";
async function validateToken(req, res, next) {
const token = req.headers.authorization;
if (!token) return next(); // Allow unauthenticated for this tutorial
try {
const resp = await fetch(`${AUTH_URL}/auth/validate`, {
headers: { authorization: token },
method: "POST",
});
const data = await resp.json();
if (data.valid) req.user = data.user;
} catch (err) {
console.error("Auth service error:", err.message);
}
next();
}
app.use(validateToken);
Update appmecca.yaml¶
Add the auth service container and deploy entry:
database:
enabled: true
redis:
enabled: true
containers:
- name: backend
dockerfile: backend/Dockerfile
context: backend/
- name: frontend
dockerfile: frontend/Dockerfile
context: frontend/
- name: auth-service
dockerfile: auth-service/Dockerfile
context: auth-service/
deploy:
- name: backend
container: backend
port: 3000
path: /api
health_check:
path: /health
environment:
AUTH_SERVICE_URL: "${service:auth:internal}"
- name: auth
container: auth-service
port: 3100
health_check:
path: /health
# JWT_SECRET is a secret — set it with `mecca var set <app> JWT_SECRET=…`,
# never in this file, which is committed to your repository.
- 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}"
- name: worker
container: backend
command: "node worker.js"
Key additions:
${service:auth}resolves to the auth service's public HTTPS URL at build and deploy time — stable across redeploys- Secrets are never written into
appmecca.yaml. That file is committed to your repository; set secrets withmecca var setinstead
visibility is app-wide, not per-service
You may be tempted to add visibility: private to the auth service to keep it off the public internet. Read this first.
visibility is declared per service in the YAML, but AppMecca resolves it to a single app-level value using most-restrictive-wins: if any service in the app declares private, the entire app — frontend included — becomes private, and your users hit a login redirect on the todo list.
What private actually does is route the app through AppMecca's auth gateway: anonymous visitors are redirected to a login page, and signed-in visitors are authorised against the owning group's membership. It does not remove the service's hostname.
Flip it after registration instead, when you mean the whole app:
mecca app protect todo-app # whole app behind the auth gateway
mecca app publish todo-app # back to public
A service with no port at all has no hostname to begin with — that is the way to keep something genuinely off the public router.
Set the JWT secret¶
Sensitive values
Variables set with mecca var set are held in the platform secret store and injected into your containers at deploy time — they are never written to your repository. Do not commit secrets.
With no -b, the value is written to every branch of the app. Narrow it with -b <branch> if that is not what you want. Changes take effect on the next deploy.
Build and deploy¶
Verify communication¶
Exercise the flow through the backend:
BACKEND=https://todo-app-backend-yourgroup.dev.example.com
# Get a token
curl -X POST "$BACKEND/auth/token" \
-H "Content-Type: application/json" \
-d '{"username": "alice"}'
# Use the token (the backend validates it via the auth service)
curl "$BACKEND/api/todos" \
-H "Authorization: Bearer <token>"
Check the auth service's logs to confirm it received the validation request. The service is selected with --service:
What's next¶
In Part 8: Production Patterns, you will learn about memory limits, health check tuning, database backup strategies, and rollback.