first commit

This commit is contained in:
2026-08-17 12:18:05 +07:00
commit 261440c694
12 changed files with 576 additions and 0 deletions

79
deploy/README.md Normal file
View File

@@ -0,0 +1,79 @@
# Production deployment
This stack exposes only Caddy on ports 80 and 443. PostgreSQL and the FastAPI backend
are isolated in the private Docker network. Caddy serves the Vite SPA and proxies public
`/api/*` requests to the backend after removing the `/api` prefix.
## First deployment
1. Point the `malenia.space` A record to the VPS public IPv4 address.
2. Install Docker Engine and the Docker Compose plugin on the VPS. Allow only TCP 22, 80,
and 443 through the host firewall.
3. Clone the repository to `/opt/malenia` and create `/opt/malenia/.env` from
`.env.example`. Set file mode `600` and replace all placeholder values.
4. Put `private.pem` and `public.pem` in `/opt/malenia/keys`, set `KEYS_DIR=/opt/malenia/keys`
in `.env`, then grant access only to the backend container user:
```bash
sudo chown -R 10001:10001 /opt/malenia/keys
sudo chmod 700 /opt/malenia/keys
sudo chmod 600 /opt/malenia/keys/*.pem
```
5. Build and start PostgreSQL, then apply migrations:
```bash
docker compose --env-file .env -f compose.production.yml up -d postgres
docker compose --env-file .env -f compose.production.yml --profile tools run --rm migrate
```
6. Start the application:
```bash
docker compose --env-file .env -f compose.production.yml up -d --build
```
7. Verify `https://malenia.space/api/health/` and the primary frontend flows.
## Releases
Before each release, run frontend checks locally. On the VPS, pull the intended revision,
apply migrations, then rebuild and recreate services:
```bash
git pull --ff-only
docker compose --env-file .env -f compose.production.yml --profile tools run --rm migrate
docker compose --env-file .env -f compose.production.yml up -d --build
```
The Pally callback URL is `https://malenia.space/api/payments/pally/result`.
## Local production-like test
This test uses the same images, API proxying, and rate-limit plugin as production, but
serves HTTP on `http://localhost:8080`. It does not need a domain, TLS certificate, or VPS.
1. Create a local `.env` from `.env.example` and fill the required backend integration
settings. `POSTGRES_HOST=postgres`, `PRIVATE_KEY_FP=/run/secrets/keys/private.pem`, and
`PUBLIC_KEY_FP=/run/secrets/keys/public.pem` must remain unchanged. The local `keys/`
directory must contain both PEM files.
2. Start PostgreSQL and apply migrations:
```bash
docker compose --env-file .env -f compose.production.yml -f compose.local.yml up -d postgres
docker compose --env-file .env -f compose.production.yml -f compose.local.yml --profile tools run --rm migrate
```
3. Build and run the stack:
```bash
docker compose --env-file .env -f compose.production.yml -f compose.local.yml up -d --build
```
4. Check the frontend at `http://localhost:8080` and the backend through Caddy at
`http://localhost:8080/api/health/`.
5. Stop the local stack while preserving its database volume:
```bash
docker compose --env-file .env -f compose.production.yml -f compose.local.yml down
```

20
deploy/backend/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN addgroup --gid 10001 --system app && adduser --uid 10001 --system --ingroup app app
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./
USER app
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]

70
deploy/caddy/Caddyfile Normal file
View File

@@ -0,0 +1,70 @@
{
admin off
email {$ACME_EMAIL}
}
malenia.space {
encode zstd gzip
log {
output stdout
format json
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), geolocation=(), microphone=()"
}
@api path /api/*
route @api {
rate_limit {
zone auth {
match {
path /api/auth/login /api/auth/signup /api/auth/refresh
}
key {remote_host}
events 10
window 1m
ipv6_prefix 64
}
zone checkout {
match {
path /api/orders/checkout
}
key {remote_host}
events 10
window 10m
ipv6_prefix 64
}
zone payment_callback {
match {
path /api/payments/pally/result
}
key {remote_host}
events 60
window 1m
ipv6_prefix 64
}
zone api {
match {
not path /api/health/
}
key {remote_host}
events 120
window 1m
ipv6_prefix 64
}
}
uri strip_prefix /api
reverse_proxy backend:8000
}
root * /srv
try_files {path} /index.html
file_server
}

View File

@@ -0,0 +1,57 @@
{
admin off
}
:80 {
encode zstd gzip
log {
output stdout
format json
}
@api path /api/*
route @api {
rate_limit {
zone auth {
match {
path /api/auth/login /api/auth/signup /api/auth/refresh
}
key {remote_host}
events 10
window 1m
}
zone checkout {
match {
path /api/orders/checkout
}
key {remote_host}
events 10
window 10m
}
zone payment_callback {
match {
path /api/payments/pally/result
}
key {remote_host}
events 60
window 1m
}
zone api {
match {
not path /api/health/
}
key {remote_host}
events 120
window 1m
}
}
uri strip_prefix /api
reverse_proxy backend:8000
}
root * /srv
try_files {path} /index.html
file_server
}

25
deploy/caddy/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
ARG CADDY_VERSION=2.10.2
ARG CADDYFILE=deploy/caddy/Caddyfile
FROM node:22-alpine AS frontend
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM caddy:${CADDY_VERSION}-builder AS builder
RUN xcaddy build \
--with github.com/mholt/caddy-ratelimit@5625512f24f6f59d6f64fb3aafe5eecff0b286db
FROM caddy:${CADDY_VERSION}-alpine
ARG CADDYFILE
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY ${CADDYFILE} /etc/caddy/Caddyfile
COPY --from=frontend /app/dist/ /srv/