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

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.env
.env.*
!.env.example
**/.env
**/.env.*
**/__pycache__
**/*.py[cod]
**/.pytest_cache
**/.ruff_cache
**/venv
**/.venv
**/node_modules
**/dist
keys
local

28
.env.example Normal file
View File

@@ -0,0 +1,28 @@
# Public TLS certificate registration address.
ACME_EMAIL=admin@malenia.space
# Pin the custom Caddy base image version used during build.
CADDY_VERSION=2.10.2
# PostgreSQL. Keep the database accessible only through the Docker private network.
POSTGRES_USER=malenia
POSTGRES_PASSWORD=replace-with-a-long-random-password
POSTGRES_DB=malenia
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
# Backend key files. Set an absolute VPS path in production, for example /opt/malenia/keys.
KEYS_DIR=./keys
PRIVATE_KEY_FP=/run/secrets/keys/private.pem
PUBLIC_KEY_FP=/run/secrets/keys/public.pem
# Application secrets and integrations. Do not commit the production .env file.
ACCESS_TOKEN_TTL=60
PALLY_SHOP_ID=
PALLY_TOKEN=
REMNAWAVE_BASE_URL=
REMNAWAVE_SUB_URL=
REMNAWAVE_TOKEN=
REMNAWAVE_DEFAULT_SQUADS_UUIDS=
MINIMAL_DEPOSIT=0
REFERAL_BONUS=30

184
.gitignore vendored Normal file
View File

@@ -0,0 +1,184 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
*.env
dev.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
plans
dev.sh
test_dummy.py
*.pem
tests/
local

1
backend Submodule

Submodule backend added at 865c5cb9b3

8
compose.local.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
caddy:
build:
args:
CADDYFILE: deploy/caddy/Caddyfile.local
ports: !override
- "8080:80"
environment: !reset {}

87
compose.production.yml Normal file
View File

@@ -0,0 +1,87 @@
name: malenia
services:
caddy:
build:
context: .
dockerfile: deploy/caddy/Dockerfile
args:
CADDY_VERSION: ${CADDY_VERSION:-2.10.2}
depends_on:
backend:
condition: service_healthy
environment:
ACME_EMAIL: ${ACME_EMAIL:?Set ACME_EMAIL in .env}
ports:
- "80:80"
- "443:443"
restart: unless-stopped
volumes:
- caddy_data:/data
- caddy_config:/config
networks:
- public
- private
backend:
build:
context: .
dockerfile: deploy/backend/Dockerfile
depends_on:
postgres:
condition: service_healthy
env_file: ${ENV_FILE:-.env}
restart: unless-stopped
volumes:
- ${KEYS_DIR:-./keys}:/run/secrets/keys:ro
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- private
migrate:
build:
context: .
dockerfile: deploy/backend/Dockerfile
command: ["alembic", "upgrade", "head"]
depends_on:
postgres:
condition: service_healthy
env_file: ${ENV_FILE:-.env}
profiles: ["tools"]
volumes:
- ${KEYS_DIR:-./keys}:/run/secrets/keys:ro
networks:
- private
postgres:
image: postgres:16.8-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
TZ: UTC
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
networks:
- private
volumes:
caddy_data:
caddy_config:
postgres_data:
networks:
public:
private:
internal: true

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/

1
frontend Submodule

Submodule frontend added at 76f26430ba