Scaffold FastAPI + Vue3 placeholder app with local docker compose stack
This commit is contained in:
commit
60f8399f85
10 changed files with 238 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
11
Dockerfile.dev
Normal file
11
Dockerfile.dev
Normal 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
1
Procfile
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
web: uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
59
README.md
Normal file
59
README.md
Normal 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
15
docker-compose.yml
Normal 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
33
main.py
Normal 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
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
25
static/app.js
Normal file
25
static/app.js
Normal 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
26
static/index.html
Normal 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 — 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
62
static/style.css
Normal 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; }
|
||||||
Loading…
Reference in a new issue