chore: initial hub repo structure

This commit is contained in:
asepharyana
2026-07-09 22:08:26 +07:00
commit b31fe9d188
83 changed files with 7969 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
# Infrastructure
Docker Compose and Traefik configuration for `asepharyana-hub`.
## Layout
```text
infra/
├── compose/ # One compose file per stack/service
│ ├── traefik.yml # Public reverse proxy
│ ├── shared.yml # Shared Redis
│ ├── react.yml # React SPA
│ ├── scraper.yml # Scraper API
│ ├── elysia.yml # Elysia API
│ └── rust-auth.yml # Rust auth API
├── docker/ # Dockerfiles and image runtime helpers
├── traefik/ # Static and dynamic Traefik configuration
│ ├── dynamic/ # Routers, services, middlewares, TLS certs
│ └── TRAEFIK_ENV_CONFIG.md
└── config/ # Service bootstrap configuration
```
Archived configs that are not deployed live under `docs/config/`.
## First-time setup
Create the shared Docker network before starting any service:
```bash
docker network create app-shared-net
```
Create `.env` from `.env.example` and fill production values. Do not commit `.env`.
## Deployment order
The GitHub deploy workflow combines the active compose files automatically. For manual deployment, use this order:
```bash
docker compose -f infra/compose/shared.yml up -d
docker compose -f infra/compose/traefik.yml up -d
docker compose \
-f infra/compose/react.yml \
-f infra/compose/scraper.yml \
-f infra/compose/elysia.yml \
-f infra/compose/rust-auth.yml \
up -d
```
## Environment variables
Common variables used by infra compose files:
```env
DATABASE_URL=
GITHUB_TOKEN=
JWT_SECRET=
SHARED_REDIS_EXPOSE=127.0.0.1:6379:6379
```
Traefik certificate path variables are optional because `infra/compose/traefik.yml` provides production-compatible defaults. See `infra/traefik/TRAEFIK_ENV_CONFIG.md` for the full list.
## Traefik
Traefik reads dynamic config from `infra/traefik/dynamic/`:
- `apps.yaml` — routers and upstream services
- `middlewares.yaml` — shared middleware chains
- `ssl.yaml` — TLS certificates
The primary certificate intentionally pairs `asephstech.pem` with `asephscloud.key` to preserve the current production layout.
## Validation
Run syntax checks after editing infra YAML:
```bash
python - <<'PY'
import pathlib, yaml
for path in pathlib.Path('infra').rglob('*.yml'):
with path.open() as fh:
yaml.safe_load(fh)
print(f'OK {path}')
for path in pathlib.Path('infra').rglob('*.yaml'):
with path.open() as fh:
yaml.safe_load(fh)
print(f'OK {path}')
PY
```
Check compose rendering when Docker is available:
```bash
for f in infra/compose/*.yml; do
docker compose -f "$f" config >/dev/null && echo "OK $f"
done
```
+24
View File
@@ -0,0 +1,24 @@
services:
elysia-api:
container_name: elysia-api
image: ghcr.io/asepharyana/asepharyana-hub/elysia-api:sha-0899edc
restart: always
networks:
app-shared-net:
aliases:
- elysia-api
env_file:
- ../../.env
environment:
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
- DATABASE_URL=${DATABASE_URL}
- GITHUB_TOKEN=${GITHUB_TOKEN}
- PORT=4092
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otlp-metrics-backend:4317
- OTEL_SERVICE_NAME=elysia-api
networks:
app-shared-net:
name: app-shared-net
external: true
+13
View File
@@ -0,0 +1,13 @@
services:
react-web:
container_name: react-web
image: ghcr.io/asepharyana/asepharyana-hub/react-web:sha-0899edc
restart: always
networks: [app-shared-net]
environment:
- VITE_API_URL=https://scraper.asepharyana.my.id/api
- VITE_ELYSIA_URL=https://elysia.asepharyana.my.id
networks:
app-shared-net:
name: app-shared-net
external: true
+20
View File
@@ -0,0 +1,20 @@
services:
rust-auth:
container_name: rust-auth-api
image: ghcr.io/asepharyana/asepharyana-hub/rust-auth:sha-0899edc
restart: always
networks:
app-shared-net:
aliases:
- rust-auth
env_file:
- ../../.env
environment:
- PORT=3000
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otlp-metrics-backend:4317
- OTEL_SERVICE_NAME=rust-auth
networks:
app-shared-net:
name: app-shared-net
external: true
+21
View File
@@ -0,0 +1,21 @@
services:
scraper-api:
container_name: scraper-api
image: ghcr.io/asepharyana/asepharyana-hub/scraper-api:sha-0899edc
restart: always
networks:
app-shared-net:
aliases:
- scraper-api
env_file:
- ../../.env
environment:
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
- DATABASE_URL=${DATABASE_URL}
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otlp-metrics-backend:4317
- OTEL_SERVICE_NAME=scraper-api
networks:
app-shared-net:
name: app-shared-net
external: true
+21
View File
@@ -0,0 +1,21 @@
services:
redis:
container_name: redis
image: 'redis:alpine'
restart: always
networks:
app-shared-net:
aliases:
- redis
ports:
- '${SHARED_REDIS_EXPOSE:-127.0.0.1:6379:6379}'
volumes:
- 'redis_data:/data'
networks:
app-shared-net:
name: app-shared-net
external: true
volumes:
redis_data: null
+74
View File
@@ -0,0 +1,74 @@
services:
traefik:
container_name: traefik
image: traefik:v3.6
restart: always
sysctls:
- net.core.somaxconn=65535
- net.ipv4.ip_local_port_range=1024 65535
ulimits:
nofile:
soft: 1048576
hard: 1048576
ports:
- '80:80'
- '443:443'
networks:
- app-shared-net
extra_hosts:
- 'host.docker.internal:10.0.1.1'
command:
- '--api.dashboard=true'
- '--api.insecure=false'
- '--providers.docker=true'
- '--providers.docker.endpoint=unix:///var/run/docker.sock'
- '--providers.docker.exposedByDefault=false'
- '--providers.docker.network=app-shared-net'
- '--providers.docker.watch=true'
- '--providers.file.directory=/etc/traefik/dynamic'
- '--providers.file.watch=true'
- '--entryPoints.web.address=:80'
- '--entryPoints.web.http.redirections.entryPoint.to=websecure'
- '--entryPoints.web.http.redirections.entryPoint.scheme=https'
- '--accesslog=true'
- '--accesslog.bufferingsize=100'
- '--log.level=INFO'
- '--log.format=json'
- '--entryPoints.web.transport.respondingTimeouts.readTimeout=0'
- '--entryPoints.web.transport.respondingTimeouts.writeTimeout=0'
- '--entryPoints.web.transport.respondingTimeouts.idleTimeout=0'
- '--entryPoints.web.transport.lifeCycle.requestAcceptGraceTimeout=15s'
- '--entryPoints.web.transport.lifeCycle.graceTimeOut=10s'
- '--entryPoints.websecure.transport.respondingTimeouts.readTimeout=0'
- '--entryPoints.websecure.transport.respondingTimeouts.writeTimeout=0'
- '--entryPoints.websecure.transport.respondingTimeouts.idleTimeout=0'
- '--entryPoints.websecure.transport.lifeCycle.requestAcceptGraceTimeout=15s'
- '--entryPoints.websecure.transport.lifeCycle.graceTimeOut=10s'
- '--entryPoints.websecure.address=:443'
- '--experimental.plugins.real-ip.moduleName=github.com/soulbalz/traefik-real-ip'
- '--experimental.plugins.real-ip.version=v1.0.3'
- '--experimental.plugins.blockpath.moduleName=github.com/traefik/plugin-blockpath'
- '--experimental.plugins.blockpath.version=v0.2.1'
environment:
- DOCKER_API_VERSION=1.41
- GOMEMLIMIT=4096MiB
- GOGC=200
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ${TRAEFIK_CONFIG_PATH:-/root/asepharyana-hub/infra/traefik/dynamic}:/etc/traefik/dynamic:ro
- ${TRAEFIK_CERT_ASEPHARYANA_MY_ID_PEM:-/root/asepharyana.my.id.pem}:/etc/traefik/certs/asepharyana.my.id.pem:ro
- ${TRAEFIK_CERT_ASEPHARYANA_MY_ID_KEY:-/root/asepharyana.my.id.key}:/etc/traefik/certs/asepharyana.my.id.key:ro
- ${TRAEFIK_CERT_ASEPHARYANA_WEB_ID_PEM:-/root/asepharyana.web.id.pem}:/etc/traefik/certs/asepharyana.web.id.pem:ro
- ${TRAEFIK_CERT_ASEPHARYANA_WEB_ID_KEY:-/root/asepharyana.web.id.key}:/etc/traefik/certs/asepharyana.web.id.key:ro
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.traefik.rule=Host(`traefik.asepharyana.my.id`) || Host(`traefik.asepharyana.web.id`)'
- 'traefik.http.routers.traefik.service=api@internal'
- 'traefik.http.routers.traefik.entrypoints=websecure'
- 'traefik.http.routers.traefik.tls=true'
- 'traefik.http.routers.traefik.middlewares=admin-chain@file'
networks:
app-shared-net:
name: app-shared-net
external: true
@@ -0,0 +1,10 @@
-- Create database if not exists
CREATE DATABASE IF NOT EXISTS `tracer_study`;
-- Create dedicated user for tracer_study
CREATE USER IF NOT EXISTS 'tracerstudy'@'%' IDENTIFIED BY 'tracerstudy_secret';
GRANT ALL PRIVILEGES ON `tracer_study`.* TO 'tracerstudy'@'%';
-- Flush privileges
FLUSH PRIVILEGES;
File diff suppressed because one or more lines are too long
+26
View File
@@ -0,0 +1,26 @@
# build stage
FROM oven/bun:1-alpine AS builder
WORKDIR /app
# install dependencies with cache mounts
COPY apps/elysia/package.json apps/elysia/bun.lock ./
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile
# build the application
COPY apps/elysia ./
RUN bun run build
# runtime stage
FROM oven/bun:1-distroless
WORKDIR /app
# copy build artifacts
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
# distroless uses nonroot user (UID 65532) by default, or we can use it
USER nonroot
EXPOSE 4092
CMD ["run", "dist/index.js"]
+75
View File
@@ -0,0 +1,75 @@
import { existsSync, readFileSync } from "node:fs"
import { resolve, sep } from "node:path"
const distDir = resolve("./dist")
const indexPath = resolve(distDir, "index.html")
const contentTypes = {
html: "text/html; charset=utf-8",
css: "text/css; charset=utf-8",
js: "application/javascript; charset=utf-8",
json: "application/json; charset=utf-8",
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
ico: "image/x-icon",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
}
function responseFromFile(filePath, headers) {
try {
return new Response(readFileSync(filePath), { headers })
} catch (error) {
if (error?.code === "ENOENT") {
return new Response("Not Found", { status: 404 })
}
console.error("File read error:", error)
return new Response("Internal Server Error", { status: 500 })
}
}
function serveIndex() {
return responseFromFile(indexPath, {
"Content-Type": contentTypes.html,
"Cache-Control": "no-cache",
})
}
function serveFile(filePath, pathname) {
const ext = filePath.split(".").pop() || ""
return responseFromFile(filePath, {
"Content-Type": contentTypes[ext] || "application/octet-stream",
"Cache-Control": pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache",
})
}
Bun.serve({
port: 80,
hostname: "0.0.0.0",
fetch(req) {
const url = new URL(req.url)
const pathname = url.pathname
const filePath = resolve(distDir, pathname.slice(1))
const insideDist = filePath === distDir || filePath.startsWith(`${distDir}${sep}`)
if (!insideDist) {
return new Response("Forbidden", { status: 403 })
}
if (pathname !== "/" && existsSync(filePath)) {
return serveFile(filePath, pathname)
}
if (!pathname.includes(".") && existsSync(indexPath)) {
return serveIndex()
}
return new Response("Not Found", { status: 404 })
},
})
+16
View File
@@ -0,0 +1,16 @@
# ─── Stage 1: Build ─────────────────────────────────────────────────────────
FROM oven/bun:1 AS builder
WORKDIR /app
COPY apps/react/package.json apps/react/bun.lock ./
RUN bun install --frozen-lockfile
COPY apps/react .
RUN bun run build
# ─── Stage 2: Runtime (Bun static server) ──────────────────────────────────
FROM oven/bun:1-alpine
WORKDIR /app
COPY infra/docker/react-server.js ./server.js
COPY --from=builder /app/dist ./dist
EXPOSE 80
CMD ["bun", "server.js"]
+42
View File
@@ -0,0 +1,42 @@
# Use cargo-chef for dependency caching
FROM lukemathwalker/cargo-chef:latest-rust-1.89.0 AS chef
WORKDIR /app
FROM chef AS planner
COPY apps/rust-auth .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Utilize buildkit cache mounts for cargo
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo chef cook --release --recipe-path recipe.json
# Build application
COPY apps/rust-auth .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release && \
cp target/release/rust-auth /app/rust-auth
# Final runtime image
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
libssl3 \
&& rm -rf /var/lib/apt/lists/*
# Add non-root user
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -s /bin/sh appuser
WORKDIR /app
COPY --from=builder /app/rust-auth /app/rust-auth
# Run as non-root
USER appuser
EXPOSE 3000
CMD ["./rust-auth"]
+50
View File
@@ -0,0 +1,50 @@
# Use cargo-chef for dependency caching
FROM lukemathwalker/cargo-chef:latest-rust-1.89.0 AS chef
WORKDIR /app
# Install Node.js if needed for build scripts
RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs \
&& rm -rf /var/lib/apt/lists/*
FROM chef AS planner
COPY apps/scraper .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Utilize buildkit cache mounts for cargo
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo chef cook --release --recipe-path recipe.json
# Build application
COPY apps/scraper .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release && \
cp target/release/scraper /app/scraper
# Final runtime image
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
libssl3 \
chromium \
fonts-liberation \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
# Add non-root user
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -s /bin/sh appuser
WORKDIR /app
COPY --from=builder /app/scraper /app/scraper
# Run as non-root
USER appuser
EXPOSE 4091
CMD ["./scraper"]
+61
View File
@@ -0,0 +1,61 @@
# Traefik Environment Configuration
This document describes environment variables used to configure Traefik certificate and config paths in production deployments.
## Certificate Path Environment Variables
All certificate paths support environment variable substitution with safe fallback defaults. This allows flexible certificate management across different deployment environments without modifying compose files.
### Configuration Variables
| Variable | Description | Default Path | Purpose |
| ------------------------------------- | --------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------- |
| `TRAEFIK_CONFIG_PATH` | Directory containing dynamic Traefik configuration files (YAML) | `/root/asepharyana-hub/infra/traefik/dynamic` | Location of middleware, router, and service definitions |
| `TRAEFIK_CERT_ASEPHARYANA_MY_ID_PEM` | Certificate file for asepharyana.my.id | `/root/asepharyana.my.id.pem` | SSL/TLS certificate for asepharyana.my.id domain |
| `TRAEFIK_CERT_ASEPHARYANA_MY_ID_KEY` | Key file for asepharyana.my.id | `/root/asepharyana.my.id.key` | SSL/TLS private key for asepharyana.my.id domain |
| `TRAEFIK_CERT_ASEPHARYANA_WEB_ID_PEM` | Certificate file for asepharyana.web.id | `/root/asepharyana.web.id.pem` | SSL/TLS certificate for asepharyana.web.id domain |
| `TRAEFIK_CERT_ASEPHARYANA_WEB_ID_KEY` | Key file for asepharyana.web.id | `/root/asepharyana.web.id.key` | SSL/TLS private key for asepharyana.web.id domain |
## Usage
### Default Behavior (Production)
If no environment variables are set, Traefik will use the default paths shown above. This is suitable for production deployments where certificates are installed at these standard locations.
```bash
docker compose -f infra/compose/traefik.yml up -d
```
### Custom Paths (Custom Deployments)
To override paths for a custom deployment, set environment variables before starting services:
```bash
export TRAEFIK_CONFIG_PATH=/etc/traefik/custom-dynamic
docker compose -f infra/compose/traefik.yml up -d
```
### Via .env File
Create or update your `.env` file in the deployment directory:
```env
TRAEFIK_CONFIG_PATH=/root/asepharyana-hub/infra/traefik/dynamic
TRAEFIK_CERT_ASEPHARYANA_MY_ID_PEM=/root/asepharyana.my.id.pem
TRAEFIK_CERT_ASEPHARYANA_MY_ID_KEY=/root/asepharyana.my.id.key
TRAEFIK_CERT_ASEPHARYANA_WEB_ID_PEM=/root/asepharyana.web.id.pem
TRAEFIK_CERT_ASEPHARYANA_WEB_ID_KEY=/root/asepharyana.web.id.key
```
Then deploy:
```bash
docker compose --env-file .env -f infra/compose/traefik.yml up -d
```
## Notes
- All certificate paths use read-only mounts (`:ro`) for security
- If a certificate file is missing at the specified path, Docker volume mounting will fail—ensure certificates exist before starting Traefik
- The dynamic configuration directory must contain valid YAML files for Traefik to load properly
+57
View File
@@ -0,0 +1,57 @@
http:
routers:
# ── React SPA (domain root) ──
react:
rule: 'Host(`asepharyana.my.id`) || Host(`asepharyana.web.id`)'
entryPoints:
- websecure
tls: {}
service: react-service
# ── Scraper API ──
scraper:
rule: 'Host(`scraper.asepharyana.my.id`) || Host(`api.asepharyana.my.id`) || Host(`scraper.asepharyana.web.id`) || Host(`api.asepharyana.web.id`)'
entryPoints:
- websecure
tls: {}
middlewares:
- common-chain@file
service: scraper-service
# ── Elysia API ──
elysia:
rule: 'Host(`elysia.asepharyana.my.id`) || Host(`elysia.asepharyana.web.id`)'
entryPoints:
- websecure
tls: {}
middlewares:
- common-chain@file
service: elysia-service
# ── Rust Auth API ──
rust-auth:
rule: 'Host(`auth.asepharyana.my.id`) || Host(`auth.asepharyana.web.id`)'
entryPoints:
- websecure
tls: {}
middlewares:
- common-chain@file
service: rust-auth-service
services:
react-service:
loadBalancer:
servers:
- url: 'http://react-web:80'
scraper-service:
loadBalancer:
servers:
- url: 'http://scraper-api:4091'
elysia-service:
loadBalancer:
servers:
- url: 'http://elysia-api:4092'
rust-auth-service:
loadBalancer:
servers:
- url: 'http://rust-auth:3000'
+74
View File
@@ -0,0 +1,74 @@
http:
middlewares:
secure-headers:
headers:
sslRedirect: true
forceSTSHeader: true
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
frameDeny: true
contentTypeNosniff: true
browserXSSFilter: true
referrerPolicy: 'same-origin'
customResponseHeaders:
X-Content-Type-Options: 'nosniff'
X-Frame-Options: 'DENY'
X-XSS-Protection: '1; mode=block'
Referrer-Policy: 'same-origin'
Permissions-Policy: 'geolocation=(), microphone=(), camera=()'
compress:
compress:
minResponseBodyBytes: 256
excludedContentTypes:
- 'image/*'
- 'application/octet-stream'
retry:
retry:
attempts: 3
rate-limit:
rateLimit:
average: 100
burst: 50
buffer:
buffering:
maxRequestBodyBytes: 10485760
maxResponseBodyBytes: 10485760
memRequestBodyBytes: 1048576
memResponseBodyBytes: 1048576
admin-chain:
chain:
middlewares:
- secure-headers
- compress
- retry
# ── Useful Plugins ──
real-ip:
plugin:
real-ip:
excludednetworks:
- '127.0.0.1/32'
realipheader: 'CF-Connecting-IP'
block-sensitive-paths:
plugin:
blockpath:
regex:
- "^/\\.env"
- "^/\\.git"
- '^/wp-admin'
- "^/wp-login\\.php"
- "^/config\\.php"
# ── Common Chain ──
common-chain:
chain:
middlewares:
# - real-ip
# - block-sensitive-paths
- secure-headers
- compress
- retry
- rate-limit
- buffer
+14
View File
@@ -0,0 +1,14 @@
tls:
certificates:
# Legacy production layout: asephstech.pem is paired with asephscloud.key.
- certFile: /etc/traefik/certs/asephstech.pem
keyFile: /etc/traefik/certs/asephscloud.key
- certFile: /etc/traefik/certs/asepharyana.my.id.pem
keyFile: /etc/traefik/certs/asepharyana.my.id.key
- certFile: /etc/traefik/certs/asepharyana.web.id.pem
keyFile: /etc/traefik/certs/asepharyana.web.id.key
stores:
default:
defaultCertificate:
certFile: /etc/traefik/certs/asephstech.pem
keyFile: /etc/traefik/certs/asephscloud.key
+29
View File
@@ -0,0 +1,29 @@
api:
dashboard: true
insecure: true
log:
level: INFO
format: json
accessLog: {}
entryPoints:
web:
address: ':80'
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ':443'
providers:
docker:
endpoint: 'unix:///var/run/docker.sock'
exposedByDefault: false
network: app-shared-net
file:
directory: /etc/traefik/dynamic
watch: true