Scaffold FastAPI + Vue3 placeholder app with local docker compose stack

This commit is contained in:
Chris Amow 2026-08-09 23:34:01 +00:00
commit 60f8399f85
10 changed files with 238 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__/
*.pyc
.venv/
.env

11
Dockerfile.dev Normal file
View file

@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

1
Procfile Normal file
View file

@ -0,0 +1 @@
web: uvicorn main:app --host 0.0.0.0 --port 8000

59
README.md Normal file
View file

@ -0,0 +1,59 @@
# chart
FastAPI backend + Vue 3 (from CDN, no build step) served at
<https://chart.amow.com>.
Currently a placeholder: the frontend calls `/api/hello` and prints the JSON.
## Local development
```bash
docker compose up --build
```
Then open <http://localhost:8000>. The source tree is bind-mounted and uvicorn
runs with `--reload`, so edits to `main.py` or `static/` take effect without a
rebuild. Rebuild only when `requirements.txt` changes.
Without Docker:
```bash
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload
```
## Layout
| Path | Purpose |
|---|---|
| `main.py` | FastAPI app — JSON under `/api`, serves the SPA at `/` |
| `static/` | `index.html`, `app.js`, `style.css` — Vue 3 loaded from unpkg |
| `requirements.txt` | Python deps |
| `Procfile` | Start command; **nixpacks needs this** or the deploy has nothing to run |
| `Dockerfile.dev`, `docker-compose.yml` | Local dev only — production does not use them |
## Deployment
Push to `main` → Forgejo webhook → Coolify rebuilds with nixpacks → live in
roughly 25 seconds.
```bash
git push
```
Two things to know before changing the deploy config:
- The Coolify domain is registered as `https://chart.amow.com:8000`. The port
suffix is how Coolify decides the Traefik target port; drop it and every
request 502s while the container looks perfectly healthy. It does not appear
in the public URL.
- Ignore a 502 in the first minute after a deploy — that's the rolling container
swap, and it clears on its own.
Manual redeploy:
```bash
curl -X POST -H "Authorization: Bearer $(cat ~/.coolify-token)" \
"http://127.0.0.1:8000/api/v1/deploy?uuid=$COOLIFY_UUID&force=true"
```

15
docker-compose.yml Normal file
View file

@ -0,0 +1,15 @@
# Local development stack. Production (chart.amow.com) is built by Coolify
# with nixpacks straight from this repo — it does not use this file.
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "8000:8000"
volumes:
# Bind-mount the source so --reload picks up edits without a rebuild.
- .:/app
environment:
- PYTHONDONTWRITEBYTECODE=1
- PYTHONUNBUFFERED=1

33
main.py Normal file
View file

@ -0,0 +1,33 @@
"""chart.amow.com — FastAPI backend.
Placeholder app: serves the Vue 3 single-page frontend from static/ and a
couple of JSON endpoints under /api. Replace the endpoints as the real app
takes shape; the serving/deploy wiring below does not need to change.
"""
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
BASE_DIR = Path(__file__).parent
STATIC_DIR = BASE_DIR / "static"
app = FastAPI(title="chart")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/api/health")
def health():
return {"status": "ok", "service": "chart"}
@app.get("/api/hello")
def hello():
return {"msg": "backend is live"}
@app.get("/")
def index():
return FileResponse(STATIC_DIR / "index.html")

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
fastapi
uvicorn[standard]

25
static/app.js Normal file
View file

@ -0,0 +1,25 @@
const { createApp, ref } = Vue;
createApp({
setup() {
const result = ref('');
const error = ref('');
const loading = ref(false);
async function ping() {
loading.value = true;
error.value = '';
try {
const res = await fetch('/api/hello');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
result.value = JSON.stringify(await res.json(), null, 2);
} catch (e) {
error.value = String(e);
} finally {
loading.value = false;
}
}
return { result, error, loading, ping };
},
}).mount('#app');

26
static/index.html Normal file
View file

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>chart</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
</head>
<body>
<div id="app">
<h1>chart</h1>
<p class="sub">FastAPI + Vue 3 &mdash; placeholder</p>
<div class="card">
<button @click="ping" :disabled="loading">
{{ loading ? 'calling…' : 'call /api/hello' }}
</button>
<pre v-if="result">{{ result }}</pre>
<p v-if="error" class="error">{{ error }}</p>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

62
static/style.css Normal file
View file

@ -0,0 +1,62 @@
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #16181d;
--muted: #6b7280;
--line: #e4e6eb;
--accent: #2f6feb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14161a;
--fg: #e8eaed;
--muted: #9aa1ab;
--line: #2a2e35;
--accent: #6d9bf5;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 3rem 1.5rem;
background: var(--bg);
color: var(--fg);
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
#app { max-width: 40rem; margin: 0 auto; }
h1 { margin: 0; font-size: 1.75rem; letter-spacing: -0.01em; }
.sub { margin: 0.25rem 0 2rem; color: var(--muted); }
.card {
border: 1px solid var(--line);
border-radius: 10px;
padding: 1.25rem;
}
button {
font: inherit;
padding: 0.5rem 1rem;
border: 0;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
button:disabled { opacity: 0.6; cursor: default; }
pre {
margin: 1rem 0 0;
padding: 0.75rem;
overflow-x: auto;
border-radius: 6px;
background: color-mix(in srgb, var(--fg) 6%, transparent);
}
.error { color: #d24b4b; }