chore(deploy): add production caddy setup

This commit is contained in:
2026-07-14 10:43:12 +03:30
parent c122aab57c
commit e99060de41
13 changed files with 195 additions and 20 deletions

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
.git/
.mypy_cache/
.pytest_cache/
.ruff_cache/
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.env
.env.*
!.env.example
!.env.production.example
htmlcov/
build/
dist/
mongo-data/
redis-data/
rabbitmq-data/

24
.env.production.example Normal file
View File

@@ -0,0 +1,24 @@
APP_ENV=production
MONGO_DB_NAME=gapido_auth
JWT_SECRET_KEY=replace-with-a-long-random-production-secret
JWT_ISSUER=gapido-auth
ACCESS_TOKEN_TTL_SECONDS=900
REFRESH_TOKEN_TTL_SECONDS=604800
OTP_TTL_SECONDS=120
OTP_MAX_ATTEMPTS=5
OTP_REQUEST_LIMIT=3
OTP_REQUEST_WINDOW_SECONDS=300
# Choose exactly one real provider in production: kavenegar or sms_ir.
SMS_PROVIDER=sms_ir
KAVENEGAR_API_KEY=
KAVENEGAR_LOGIN_TEMPLATE=login-otp
SMS_IR_API_KEY=replace-with-real-sms-ir-key
SMS_IR_VERIFY_TEMPLATE_ID=570574
ADMIN_MOBILE=989120000000
DEMO_ENABLE_DEBUG_OTP=false
DEMO_DEBUG_SMS_TTL_SECONDS=300
GRPC_HOST=0.0.0.0
GRPC_PORT=50051

2
.gitignore vendored
View File

@@ -24,6 +24,7 @@ htmlcov/
.env
.env.*
!.env.example
!.env.production.example
# IDE and OS
.idea/
@@ -37,4 +38,3 @@ Thumbs.db
mongo-data/
redis-data/
rabbitmq-data/

View File

@@ -8,14 +8,16 @@ WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& groupadd --system app \
&& useradd --system --gid app --home-dir /app app \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml README.md ./
COPY src ./src
COPY proto ./proto
RUN pip install --no-cache-dir ".[dev]" \
&& python -m gapido_auth.tools.generate_proto
RUN pip install --no-cache-dir "."
USER app
CMD ["python", "-m", "gapido_auth.transport.grpc.server"]

View File

@@ -13,8 +13,10 @@ typecheck:
mypy
compose-up:
docker compose up --build
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build
compose-down:
docker compose down --remove-orphans
compose-prod-config:
docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml config

View File

@@ -14,11 +14,29 @@ Python gRPC OTP authentication service with MongoDB, Redis, RabbitMQ, and select
```bash
cp .env.example .env
docker compose up --build
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build
```
The gRPC service listens on `localhost:50051`. The demo UI is available at `http://localhost:8080`. RabbitMQ management is available at `http://localhost:15672` with `guest` / `guest`.
## Production Deployment
Production uses Caddy as the only public entrypoint for `https://gapido.amiirkhl.ir`.
```bash
cp .env.production.example .env.production
docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
Before running production, set real values in `.env.production`:
- `JWT_SECRET_KEY`
- `ADMIN_MOBILE`
- `SMS_PROVIDER`
- the selected SMS provider credentials
Only ports `80` and `443` are published in the production Compose overlay. MongoDB, Redis, RabbitMQ, gRPC, and the FastAPI demo service stay private on the Docker network.
## SMS Provider
The SMS integration uses the Strategy pattern behind the `SmsClient` port. Select the provider with:
@@ -53,6 +71,8 @@ python -m gapido_auth.tools.generate_proto
pytest
```
The production Docker image installs runtime dependencies only. Development tools are installed locally through `.[dev]`.
## gRPC Methods
- `RequestOtp`: public; creates a short-lived OTP and publishes an SMS job.

15
deploy/caddy/Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
gapido.amiirkhl.ir {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
-Server
}
reverse_proxy demo-app:8080
}

24
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
auth-service:
ports:
- "50051:50051"
demo-app:
ports:
- "8080:8080"
environment:
DEMO_ENABLE_DEBUG_OTP: "true"
mongo:
ports:
- "27017:27017"
redis:
ports:
- "6379:6379"
rabbitmq:
ports:
- "5672:5672"
- "15672:15672"

44
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,44 @@
services:
auth-service:
environment:
SMS_PROVIDER: ${SMS_PROVIDER:?set SMS_PROVIDER in production env}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?set JWT_SECRET_KEY in production env}
ADMIN_MOBILE: ${ADMIN_MOBILE:?set ADMIN_MOBILE in production env}
sms-worker:
environment:
SMS_PROVIDER: ${SMS_PROVIDER:?set SMS_PROVIDER in production env}
KAVENEGAR_API_KEY: ${KAVENEGAR_API_KEY:-}
SMS_IR_API_KEY: ${SMS_IR_API_KEY:-}
demo-app:
environment:
DEMO_ENABLE_DEBUG_OTP: "false"
depends_on:
auth-service:
condition: service_healthy
caddy:
image: caddy:2.8-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
demo-app:
condition: service_healthy
healthcheck:
test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"]
interval: 30s
timeout: 5s
retries: 3
volumes:
caddy-data:
caddy-config:

View File

@@ -21,8 +21,18 @@ services:
ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000}
GRPC_HOST: 0.0.0.0
GRPC_PORT: 50051
ports:
- "50051:50051"
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import grpc; from grpc_health.v1 import health_pb2, health_pb2_grpc; channel=grpc.insecure_channel('localhost:50051'); stub=health_pb2_grpc.HealthStub(channel); stub.Check(health_pb2.HealthCheckRequest(), timeout=3)",
]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
depends_on:
mongo:
condition: service_started
@@ -51,32 +61,31 @@ services:
environment:
AUTH_GRPC_TARGET: auth-service:50051
REDIS_URL: redis://redis:6379/0
DEMO_ENABLE_DEBUG_OTP: "true"
ports:
- "8080:8080"
DEMO_ENABLE_DEBUG_OTP: ${DEMO_ENABLE_DEBUG_OTP:-true}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz', timeout=3).read()"]
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
depends_on:
auth-service:
condition: service_started
condition: service_healthy
redis:
condition: service_started
mongo:
image: mongo:7
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
redis:
image: redis:7-alpine
ports:
- "6379:6379"
rabbitmq:
image: rabbitmq:3.13-management-alpine
ports:
- "5672:5672"
- "15672:15672"
volumes:
- rabbitmq-data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 5s
@@ -85,3 +94,4 @@ services:
volumes:
mongo-data:
rabbitmq-data:

View File

@@ -9,7 +9,6 @@ dependencies = [
"grpcio==1.68.1",
"grpcio-health-checking==1.68.1",
"grpcio-reflection==1.68.1",
"grpcio-tools==1.68.1",
"httpx==0.28.1",
"motor==3.6.0",
"protobuf==5.29.2",
@@ -22,6 +21,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"grpcio-tools==1.68.1",
"mypy==1.14.1",
"pytest==8.3.4",
"pytest-asyncio==0.25.2",

View File

@@ -97,6 +97,11 @@ async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/auth/request-otp")
async def request_otp(
body: RequestOtpBody,

View File

@@ -105,6 +105,16 @@ def test_demo_auth_flow_forwards_to_grpc_client() -> None:
app.dependency_overrides.clear()
def test_healthz() -> None:
client = TestClient(app)
try:
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
finally:
client.close()
def test_debug_otp_endpoint_reads_redis_when_enabled() -> None:
app.dependency_overrides[get_app_settings] = lambda: Settings(demo_enable_debug_otp=True)
app.dependency_overrides[get_debug_redis] = lambda: FakeDebugRedis()