Going Swiss-Sovereign: Infomaniak AI, Jelastic Pitfalls, and How We Shipped Production on a VPS with Traefik
Source: Dev.to
Source: Dev.to
Swiss‑Contract.ai – Building a Swiss‑Sovereign AI Contract Analyzer
swisscontract.ai promises that your employment‑contract text never leaves Switzerland. This week I finally delivered on that promise. It took two days, three infrastructure switches, and a surprising number of npm bugs. Here’s the full story.
## The Goal: Swiss‑Sovereign AI
The app was originally running on Vercel with Claude—great for quality, but “Swiss‑sovereign” means the data must stay in Switzerland. Infomaniak, a Geneva‑based cloud provider, offers an AI API backed by open models in their Swiss data centres. I wanted to test those models **and** swap the hosting at the same time.
---Testing the Models
I evaluated five Infomaniak AI models against a real Swiss employment contract.
| Model | Time | Valid JSON | Accuracy |
|---|---|---|---|
| qwen3 | 23.9 s | ✅ | Good |
| moonshotai/Kimi‑K2.5 | 26.1 s | ✅ | Best |
| llama3 | 15.7 s | ✅ | Decent |
| mistral3 | 8.2 s | ❌ (markdown) | Good |
| swiss‑ai/Apertus‑70B | 26.5 s | ❌ (markdown) | Good |
Key finding
Infomaniak’s v2 API does not support response_format: { type: "json_object" }; it only accepts json_schema. Consequently, you must prompt‑engineer the model to produce JSON and then post‑process any malformed output.
Production choice
Apertus‑70B – a Swiss‑AI model developed by EPFL and the Swiss AI Initiative, hosted on Infomaniak’s Swiss infrastructure.
- Its JSON output required the most fixing, but the overall quality on Swiss employment contracts was sufficient for production.
- qwen3 and Kimi‑K2.5 remain available as fallbacks.
The JSON‑Repair Rabbit Hole
Open‑source models are still worse than Claude at producing valid JSON on demand. Over four commits I built an extractJSON() pipeline that handles every failure mode I encountered:
function extractJSON(text: string): string {
// 1️⃣ Strip any pre‑amble (e.g., Qwen‑3’s reasoning)
text = text.replace(/[\s\S]*?/g, '').trim();
// 2️⃣ Extract from ```json``` fences if they exist
const fenceMatch = text.match(/```json\s*([\s\S]*?)```/);
if (fenceMatch) {
text = fenceMatch[1];
} else {
// 3️⃣ Otherwise grab the outermost {...} object
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start !== -1 && end !== -1) text = text.slice(start, end + 1);
}
// 4️⃣ Strip control characters
text = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
// 5️⃣ Fix trailing commas
text = text.replace(/,(\s*[}\]])/g, '$1');
// 6️⃣ Unwrap nested arrays [[{...}]] → [{...}]
text = text.replace(/^\[\[/, '[').replace(/\]\]$/, ']');
// 7️⃣ Repair truncated JSON (close unclosed {, [, ")
return repairTruncatedJSON(text);
}repairTruncatedJSON
The hardest part – it tracks open brackets and quotes with a stack and closes any that remain open at the end of the string. This is needed because real contracts are long and models often hit their token limits mid‑JSON.
Why the input cap was introduced (and later removed)
- Apertus‑70B kept failing on very long inputs even with an 8192‑token output window.
- A quick mitigation was to cap the input at 8 000 characters, keeping the model in its reliable range while the parser was being hardened.
- Once the full repair pipeline was solid and
jsonrepairwas added as a safety net, the cap was removed. - Now the parser, not truncation, is the real fix – the entire contract text can be fed in safely.
Hosting: Jelastic → VPS → Docker + Traefik
I wanted to keep everything on Infomaniak. They offer Jelastic Cloud – a PaaS with container environments. In theory: perfect.
What Went Wrong with Jelastic
| Issue | Details |
|---|---|
| SSH‑key registration is UI‑only | The API endpoints return 404. After wasting time, I switched to their exec API (shell commands over HTTP). |
| Exec API times out under load | Running npm ci via the exec API would return before npm finished. The process kept running, but the API reported success, leading to stale builds. |
| Slow deployments corrupt builds | A slow deployment would corrupt the build; the only fix was to re‑trigger, which is unacceptable for CI/CD. |
Plain VPS – First Attempt (pm2 + nginx)
Infomaniak VPS Cloud S (Ubuntu 24.04, plain SSH). I set up:
nginx → Next.js (pm2)Two nasty bugs appeared:
npm cache corruption – The first deploy completed but
node_moduleswas only 532 KB instead of 563 MB.npm ciexited 0 while half the packages were missing.
Root cause: corrupted npm cache (TAR_ENTRY_ERROR ENOENT).
Fix:npm cache clean --force && npm install --prefer-onlineSSH heredoc + npm don’t mix – My GitHub Actions workflow used:
ssh user@host << 'REMOTE' npm ci && npm run build REMOTEnpm would not complete inside a heredoc (exit 0, no output). The TTY situation confuses npm’s progress tracking.
Fix: Write a deploy script on the server and invoke it directly:# on the CI runner scp deploy.sh user@host:/app/deploy.sh ssh user@host "bash /app/deploy.sh"
Next.js 16 Migration (Surprise)
Next.js 16.1.6 deprecates middleware.ts. You now need proxy.ts with a renamed export:
// Before (middleware.ts)
export function middleware(request: NextRequest) {
/* … */
}
// After (proxy.ts)
export function proxy(request: NextRequest) {
/* … */
}Both files can’t coexist – delete middleware.ts first.
Additionally, experimental.turbo in next.config.ts has been removed entirely.
The Architecture We Actually Shipped
After accumulating nginx + certbot + pm2 + permission hooks, I scrapped everything and rebuilt with Docker + Traefik. This is what runs in production now:
VPS:
Traefik (Docker) # SSL, routing, service discovery
swisscontract-preprod (Docker) # preprod.swisscontract.ai
swisscontract-production (Docker) # swisscontract.aiTraefik handles HTTPS termination, routing, and automatic service discovery for the two containers. The containers themselves run the Next.js app (built with the JSON‑repair pipeline) and expose the API that powers the Swiss‑sovereign contract analyzer.
TL;DR
- Swapped from Vercel + Claude to Infomaniak + Apertus‑70B to keep data in Switzerland.
- Built a robust
extractJSON()/repairTruncatedJSONpipeline to cope with malformed JSON from open models. - Jelastic proved unusable; a plain VPS worked after fixing npm cache and SSH‑heredoc issues.
- Next.js 16 migration forced a small code‑base change.
- Final production stack: Docker + Traefik on an Infomaniak VPS, serving the Swiss‑sovereign contract analyzer.
Now the promise holds: your employment‑contract text truly never leaves Switzerland. 🚀
Each branch has its own GitHub Actions workflow that builds one Docker image and deploys one container. Traefik discovers it via Docker labels and handles SSL via Let’s Encrypt ACME automatically. Certificates live in a Docker volume — no certbot, no cron, no renewal hooks.
# ~/traefik/docker-compose.yml — on VPS, not in repo
services:
traefik:
image: traefik:latest
command:
- --certificatesresolvers.letsencrypt.acme.email=...
- --providers.docker=true
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- traefik-certs:/letsencryptApp containers declare themselves via Docker labels in the GitHub Actions workflow:
docker run -d \
--name swisscontract-production \
--network traefik-net \
--label traefik.enable=true \
--label "traefik.http.routers.prod.rule=Host(`swisscontract.ai`)" \
--label traefik.http.routers.prod.tls.certresolver=letsencrypt \
…One gotcha: traefik:v3 was incompatible with Docker Engine 29 — had to use traefik:latest (v3.4.1+) and set DOCKER_API_VERSION=1.45.
Adding a new environment = new branch + new workflow, zero server changes. That’s the right abstraction.
Shipping Production
On March 6 at 19:52 CET, swisscontract.ai went live on Swiss infrastructure:
- DNS cut in Cloudflare – A record
swisscontract.ai→ Infomaniak VPS (previously pointed at Vercel) - GitHub Actions
deploy.ymlran onmain→ built:productionimage → deployed - Site check –
https://swisscontract.aireturns HTTP 200,env: "production", SSL handled by Traefik
Tagged as
v0.2.0.v0.1.0snapshots the Vercel/Anthropic era.
Pre‑launch cleanup
Before switching the DNS we removed several components that conflicted with our privacy‑first stance:
| Item | Reason |
|---|---|
| Google Analytics | GA4 sends data to US servers, contradicting the Swiss‑sovereign positioning. |
| Stateful client storage | No cookies, localStorage, or session storage – users upload a contract, receive an analysis, and close the tab. No consent banner is required because there is nothing to consent to. |
| nFADP/compliance claims | Removed from README and product copy; legal promises need a lawyer, not a README section. |
Additional hardening
- CORS in Nginx limited to
*.swisscontract.ai(previously reflected any origin). - HSTS and other security headers added.
- VPS IP scrubbed from PR bodies and release notes.
- Old Jelastic secrets deleted from GitHub environments.
.env.productionremoved (containedNEXT_PUBLIC_ENV=preprod).
What I’d Do Differently
- Skip Jelastic for small Node.js apps. Use a plain VPS with Docker; it’s simpler and more reliable.
- Test AI model JSON output on day 1. Don’t assume a new provider’s API behaves like OpenAI’s.
- Build the full JSON‑repair pipeline once. Avoid adding fixes incrementally as models fail; handle control characters, trailing commas, nested arrays, and truncation in a single pass.
- Use Traefik from the start. Nginx + certbot quickly becomes messy. Traefik + Docker labels is the right model for multi‑environment deployments from the beginning.
Current Status
Production –
https://swisscontract.ai- Live on Swiss infrastructure
- Traefik SSL ✅
Pre‑production –
https://preprod.swisscontract.ai- Same VPS, separate container ✅
CI/CD – GitHub Actions automatically deploys on pushes to
main(production) andpreprod.Contract analysis – runs on Apertus 70B (Swiss‑AI / EPFL model) hosted in the Infomaniak Swiss data centre.
Stateless front‑end – no cookies, no
localStorage, nothing is written to the browser.Alternative providers – Qwen‑3 and Kimi‑K2.5 are available as drop‑in options.
Sovereignty guarantee – your contract text never leaves Switzerland: it is processed by a Swiss model on Swiss servers.