Full-stack ready NestJS service providing dual authentication via email/password and Discord OAuth2, built on Fastify with Prisma/PostgreSQL and fully dockerized for one-command startup.
- Dual auth: Local (email + bcrypt) and Discord OAuth2 with automatic linking/creation.
- Secure tokens: JWT access (15m) + rotating refresh tokens (7d) stored as hashed entries with revocation.
- HttpOnly cookies: Access + refresh tokens delivered via secure cookies with domain + SameSite enforcement.
- Prisma/PostgreSQL: Strong relational schema tracking credentials, OAuth identities, and refresh tokens.
- Fastify hardening: Helmet, strict CORS allowlist, rate limiting, logging, and validation pipes.
- Docker-first:
docker compose up -dbuilds & runs API + Postgres, runs migrations + seed automatically. - Testing: Unit coverage for auth helpers and e2e probe for the health endpoint.
- Architecture:
auth.<domain>renders/auth/verify/:code, callsapi.<domain>only (GET /auth/email-verifications/:code/status,POST /.../confirm,POST /auth/email-verifications/resend,POST /auth/request-email-verification). No frontend DB access. - Token model: 256-bit URL-safe token, stored as
tokenHash = HMAC-SHA256(token, EMAIL_VERIFICATION_TOKEN_SECRET), one-time use, TTL (EMAIL_VERIFICATION_TTL_MINUTES, default 30). Tokens revoked on resend; attempts are rate-limited per email/IP and tracked inEmailVerificationAttempt. - Queue + delivery: Emails are enqueued in
EmailJob(encrypted payload AES-256-GCM, key derived from the token secret) and processed every 20s with exponential backoff + DLQ afterEMAIL_QUEUE_MAX_ATTEMPTS. Template ships HTML + plain text with CTA + fallback URL, senderEMAIL_FROM. - DB:
EmailVerificationToken,EmailVerificationAttempt, andEmailJobtables plusUser.emailVerifiedAt. - Config knobs:
APP_DOMAIN,AUTH_BASE_URL,API_BASE_URL,ALLOWED_ORIGINS(must includeAUTH_BASE_URL),EMAIL_FROM,EMAIL_FROM_NAME,EMAIL_REPLY_TO,MAIL_PROVIDER=smtp,SMTP_HOST,SMTP_PORT,SMTP_SECURE,SMTP_USER,SMTP_PASS,EMAIL_VERIFICATION_TOKEN_SECRET,EMAIL_VERIFICATION_TTL_MINUTES,EMAIL_VERIFICATION_REQUEST_LIMIT_PER_EMAIL,EMAIL_VERIFICATION_REQUEST_LIMIT_PER_IP,EMAIL_VERIFICATION_STATUS_LIMIT_PER_IP,EMAIL_VERIFICATION_CONFIRM_LIMIT_PER_IP,EMAIL_VERIFICATION_RATE_WINDOW_MINUTES,EMAIL_QUEUE_MAX_ATTEMPTS,EMAIL_QUEUE_BACKOFF_SECONDS. - DNS checklist: publish SPF (
v=spf1 include:<mailer> -all), DKIM (CNAME from provider), DMARC (v=DMARC1; p=quarantine; rua=mailto:dmarc@<domain>), and alignEMAIL_FROMwith the signed domain. Ensure PTR/rDNS + TLS supported by SMTP provider. Monitor bounces and complaints at the provider dashboard. - Ops runbook: Inspect queue with
SELECT id,status,attempts,lastError FROM "EmailJob" ORDER BY createdAt DESC LIMIT 20;. Jobs stuck inDEADshould be triaged (bad creds or blocked domain) before manual requeue (UPDATE "EmailJob" SET status='PENDING', attempts=0, nextRunAt=NOW() WHERE id='<id>';). Rate-limit violations return HTTP 429; do not loosen limits without enabling additional abuse controls. - Frontend sample (React-ish pseudo-code):
const api = import.meta.env.VITE_API_BASE_URL; const code = useParams().code; const [status, setStatus] = useState<'loading'|'valid'|'expired'|'used'|'invalid'|'revoked'>('loading'); useEffect(() => { fetch(`${api}/auth/email-verifications/${code}/status`) .then((res) => res.json()) .then((data) => setStatus(data.status)) .catch(() => setStatus('invalid')); }, [code]); const confirm = () => fetch(`${api}/auth/email-verifications/${code}/confirm`, { method: 'POST' }); const resend = () => fetch(`${api}/auth/email-verifications/resend`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: session.user.email }), // or userId from auth session });
- Copy environment variables and fill the Discord credentials:
cp .env.example .env
- Build and launch the stack:
docker compose up -d
- The API listens on
http://localhost:3000. - Prisma migrations and the seed user (
test@example.com/Password123!) run automatically on first boot.
- The API listens on
- Health:
curl http://localhost:3000/health - Register:
curl -X POST http://localhost:3000/auth/register \ -H "content-type: application/json" \ -d '{"email":"u@ex.com","password":"Password123!","name":"U"}' -i
- Login:
curl -X POST http://localhost:3000/auth/login \ -H "content-type: application/json" \ -d '{"email":"u@ex.com","password":"Password123!"}' -i
- Refresh:
curl -X POST http://localhost:3000/auth/refresh -H "cookie: refresh_token=..." -i - Me:
curl http://localhost:3000/me -H "cookie: access_token=..." - Discord OAuth: open
http://localhost:3000/auth/discordin a browser and complete the consent screen.
apps/api # NestJS application source
prisma # Prisma schema, migrations, seed script
docker # Container entrypoint
Key modules:
src/config: Zod-backed config loader + typed helpers.src/auth: Controllers, strategies (local, JWT, refresh, Discord), guards, DTOs, token helpers.src/users: User service +/meendpoint.src/health: Simple health check.src/prisma: Prisma service with shutdown hooks.
| Command | Description |
|---|---|
pnpm start:dev |
Run Nest with live reload |
pnpm build |
Compile TypeScript to dist/ |
pnpm test |
Run unit tests |
pnpm test:e2e |
Run e2e suite |
pnpm prisma:migrate |
Run Prisma migrations locally |
pnpm prisma:seed |
Seed database locally |
The scripts/ folder contains helpers that wrap the local stack setup and Helm deployment so you can bootstrap everything with a single command. See scripts/README.md for details.
scripts/setup.sh: copies.env, installs dependencies, waits for Postgres, runs Prisma migrations/seeds, and brings up the API container viadocker compose.scripts/deploy.sh: runshelm upgrade --installwith either thevalues.k3s.yamloverlay (dev) orvalues.eks.yaml(staging/prod); it expects an image reference and exposes--namespace/--external-db.scripts/run-all.sh: runs./scripts/setup.shfollowed by./scripts/deploy.sh, so a single invocation such asboots the stack and deploys the latest signed image../scripts/run-all.sh dev ghcr.io/my-org/lt4c-backend:sha-1234
apps/api/Dockerfileis a multi-stage build that installs deps, builds the app, and ships a minimal runtime.docker/entrypoint.shrunsprisma migrate deploy, seeds idempotently, and starts the server.- Volume
pgdatapersists database state between restarts.
- Box provisioning (
POST /admin/boxes,POST /boxes) and runtime APIs talk to the LT4C daemons using per-node credentials. Each node stores its daemon API base URL and bearer token in the database; admins can rotate them through the node management endpoints without restarting the API. - Environment knobs:
LT4C_DEFAULT_IMAGE: Docker image pushed to every daemon when provisioning new boxes.LT4C_TIMEOUT_MS/LT4C_PROVISION_TIMEOUT_MS: HTTP timeout + overall poll timeout while waiting for the daemon to reachRUNNING.LT4C_USER_AGENT: Optional identifier injected into daemon requests.
- When the daemon reports anything other than
RUNNING, the backend tears down the remote box and returns a 4xx so callers can retry safely. - Users (and admins) can supply
softwareProfileswhen creating boxes. Supported presets:nodejs20,nodejs22,python,nginx,java. The selection is persisted in the DB and forwarded to the daemon via metadata for automated bootstrapping.
- Khi tạo box hoặc gia hạn, backend tự động trừ ZCoin của người dùng bằng
product.monthlyPrice * số-tháng. Nếu provisioning/gia hạn thất bại, hệ thống hoàn xu ngay lập tức. - Boxes quá hạn > 3 ngày sẽ bị dọn tự động: backend xóa VM khỏi daemon, xóa bản ghi trong DB và gửi thông báo vào
/notifications. - Endpoint
/notificationscho phép người dùng xem lịch sử thông báo/auto-cleanup và đánh dấu đã đọc.
- Set
COOKIE_DOMAINto your real domain and adjustALLOWED_ORIGINSfor all frontends. - Replace the dev JWT secrets with strong values and rotate regularly.
- Use HTTPS everywhere so Secure cookies are honored.
- Configure monitoring around
/healthand add alerts on DB or rate-limit saturation.
- Unit:
apps/api/src/auth/utils/token.util.spec.ts - E2E:
apps/api/test/health.e2e-spec.ts
Need a one-shot deployment onto a vanilla Debian/Ubuntu VPS? Use deploy/scripts/bootstrap-vps.sh. It:
- Installs Docker + Compose, git, curl, and prerequisites.
- Creates a dedicated
lt4csystem user, clones this repository (defaults tohttps://github.com/lt4c/lt4c-backend.git), and writes a production-ready.env. - Builds & starts the stack via
docker compose, registers a systemd unit for auto-restart, and optionally sets up a self-hosted GitHub Actions runner so CI/CD continues to work even without GitHub-hosted runners.
Run it directly via curl (or copy it into your automation tooling) with the required secrets exported as environment variables:
curl -fsSL https://raw.githubusercontent.com/lt4c/lt4c-backend/main/deploy/scripts/bootstrap-vps.sh | sudo \
ACCESS_TOKEN_SECRET='...' \
REFRESH_TOKEN_SECRET='...' \
DISCORD_CLIENT_ID='...' \
DISCORD_CLIENT_SECRET='...' \
DISCORD_CALLBACK_URL='https://api.example.com/auth/discord/callback' \
COOKIE_DOMAIN='api.example.com' \
ALLOWED_ORIGINS='https://app.example.com' \
bashACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_CALLBACK_URL, ALLOWED_ORIGINS, COOKIE_DOMAIN.
REPO_URL(defaults to this GitHub repo),GIT_REF(main),APP_PORT,NODE_ENV.- Database knobs:
DB_HOST,DB_PORT,DB_NAME,DB_USER,DB_PASSWORD. When omitted, the script generates a random password and keeps everything scoped to the local Postgres container. You can also supply a completeDATABASE_URLto point at an external RDS/VPC instance. .envhandling: setPRESERVE_EXISTING_ENV=trueto keep the current file untouched on reruns.- Self-hosted runner: set
INSTALL_GITHUB_RUNNER=truealong withGITHUB_REPOSITORY=lt4c/lt4c-backend,GITHUB_RUNNER_TOKEN=<ephemeral-token>, and optionalGITHUB_RUNNER_LABELS,GITHUB_RUNNER_NAME,GITHUB_RUNNER_VERSION. - Remote image mode: set
USE_REMOTE_IMAGE=trueand provideAPI_IMAGE=ghcr.io/<org>/lt4c-backend@sha256:...(ordocker.io/<dockerhub-user>/lt4c-backend@sha256:...). If the registry is private, also exportREGISTRY_USERNAME,REGISTRY_PASSWORD, and optionallyREGISTRY_SERVERbefore running the script. In this mode the script loadsdocker-compose.remote.yml, pulls the CI-produced image, and skips local builds entirely, which keeps VPS rollouts aligned with the GitHub Actions pipeline.
After completion, the API is reachable on http://<vps-ip>:${APP_PORT:-3000} and managed through systemctl status lt4c-backend. Re-run the script anytime you need to roll out a new commit; it will pull the latest code, rebuild the images, and restart the stack idempotently.
Happy shipping!
infra/bootstrapprovisions the S3 bucket and DynamoDB lock table that every environment shares for remote state.infra/modulessupply reusable building blocks (VPC, EKS, IAM/IRSA, RDS, Secrets Manager) wired together ininfra/envs/{dev,staging,prod}.deploy/helm/appis the single chart with a basevalues.yaml, plus thevalues.k3s.yamlandvalues.eks.yamloverlays described below. Bootstrap manifests live indeploy/k8s-bootstrap/(namespace-app.yamland the External Secrets/ClusterSecretStore manifest).
- Remote state:
cd infra/bootstrap && terraform init && terraform apply -var="aws_region=us-east-1" -var="state_bucket_name=lt4c-terraform-state". - Apply the first environment (usually
dev) withcreate_github_oidc_provider=trueso the GitHub OIDC provider is created once. - Apply staging/prod pointing the backend at the shared bucket/table, passing
github_oidc_provider_arnandgithub_repository. - Capture outputs (
cluster_name,rds_endpoint,ci_role_arn,external_secrets_role_arn,github_oidc_provider_arn) and supply them to GitHub as repo variables/secrets and to the deploy workflow. - Fetch kubeconfig for each cluster once it exists:
aws eks update-kubeconfig --region us-east-1 --name lt4c-staging.
- Apply the namespace + External Secrets bootstrap manifests once per cluster:
kubectl apply -f deploy/k8s-bootstrap/namespace-app.yaml kubectl apply -f deploy/k8s-bootstrap/external-secrets.yaml
- The namespace ensures the LT4C workload has a predictable home and the bootstrap manifest seeds the
ClusterSecretStorethat External Secrets uses via IRSA. - Populate AWS Secrets Manager entries for
/lt4c/<env>/ACCESS_TOKEN_SECRET,/lt4c/<env>/REFRESH_TOKEN_SECRET,/lt4c/<env>/DISCORD_CLIENT_ID,/lt4c/<env>/DISCORD_CLIENT_SECRET, and/lt4c/<env>/DATABASE_URL.
- Use
values.k3s.yamlto switch the service to NodePort, point the ingress class tonginx, and hint that storage should use thelocal-pathstorage class. - Secrets live in the config map or SOPS/SealedSecrets, so
.Values.externalSecrets.enabledisfalseandconfig.data.DATABASE_URLcontains the connection string. - Provide the workflow with
K3S_KUBECONFIG_DEV(base64 kubeconfig) so it can authenticate before running Helm. - Manual command:
helm upgrade --install app deploy/helm/app -f deploy/helm/app/values.yaml -f deploy/helm/app/values.k3s.yaml --set image.repository=ghcr.io/your-org/lt4c-backend --set-string image.tag=sha-<commit> --set-string global.environment=dev
- GitHub Actions: trigger
deploy.ymlviaworkflow_dispatchwithenvironment=devto reuse the same pipeline used for EKS but skip AWS login.
values.eks.yamlenables the ALB ingress, sets the gp3 storage hint, and leaves External Secrets on so AWS Secrets Manager supplies sensitive values.- Use the
deployworkflow: it assumes the CI role via GitHub OIDC, verifies the Cosign signature, updates the kubeconfig withaws eks update-kubeconfig, runs the Prisma migration job, and executeshelm upgrade --installwith the digest pin. - Always redeploy the signed
image.digestfrom CI so k3s and EKS share the same artifact.
- Terraform exposes
ci_deployer_role_arnandexternal_secrets_role_arn, plus the GitHub OIDC provider ARN that the workflow needs for IRSA. Use those outputs to populate the repository variables and environments listed later in this file. - Create GitHub Environments
dev,staging,prodand bind each to the matching AWS role so the workflow can assume it through OIDC.
- Keep the same PostgreSQL instance and Secrets Manager secrets so credentials stay stable.
- Deploy the signed
sha-<commit>image to EKS via thedeployworkflow orhelm upgradeusingvalues.eks.yamland the digest from CI. - Health-check the ALB ingress and gradually shift traffic (DNS weight, health checks) before draining the k3s nodes.
- Tear down the k3s cluster once EKS is fully serving production traffic.
-
ci.yml: runs on PR + pushes tomain.- pnpm lint/typecheck/tests →
pnpm audit→ OSV scanner → Semgrep SAST. - Builds
apps/api/Dockerfile, caches layers, produces SBOM (Syft) and Trivy scan (fails on HIGH+). - Pushes the signed image to both
ghcr.io/<owner>/lt4c-backendanddocker.io/<dockerhub-user>/lt4c-backendwith tagssha-<commit>,v<package.json>,latest; Cosign signs each registry digest (keyless).
- pnpm lint/typecheck/tests →
-
deploy.yml: manual (workflow_dispatch) or semver tags (v*).- Environments:
dev,staging,prod(prod guarded by GitHub Environment approval). - Uses GitHub OIDC → AWS IAM role, verifies Cosign signature, runs Prisma migration Job from the Helm template, then
helm upgrade --installwith digest pinning and--atomic. - Records rollouts via
kubectl rollout status. Migrations are re-runnable and automatically cleaned up.
- Environments:
-
ssh-deploy.yml: auto-runs aftercisucceeds onmain(and exposes aworkflow_dispatchentry point) to deliver the signed container to the Docker + Compose VPS via SSH.- Logs into the host with the configured key, fast-forwards the repo in
$SSH_DEPLOY_PATH, exportsUSE_REMOTE_IMAGE=true+API_IMAGE=ghcr.io/<owner>/lt4c-backend:sha-<commit>, then executesdocker compose -f docker-compose.yml [-f docker-compose.remote.yml] pull && up -d --remove-orphans(second file is used automatically when present). - Cosign verification happens upstream in
ci, so this job simply reuses the attested artifact. - Pass
dry_run=truewhen dispatching manually to validate secrets/paths without touching the host.
- Logs into the host with the configured key, fast-forwards the repo in
| Variable | Purpose |
|---|---|
AWS_REGION |
e.g., us-east-1 |
AWS_ACCOUNT_ID |
Used in docs/scripts |
AWS_ROLE_ARN_DEV / AWS_ROLE_ARN_STAGING / AWS_ROLE_ARN_PROD |
OIDC roles from Terraform (module.iam.ci_deployer_role_arn) |
EKS_CLUSTER_NAME_DEV / EKS_CLUSTER_NAME_STAGING / EKS_CLUSTER_NAME_PROD |
Terraform outputs per env |
K8S_NAMESPACE_DEV / K8S_NAMESPACE_STAGING / K8S_NAMESPACE_PROD |
lt4c-<env> |
SSH_DEPLOY_PATH |
Absolute path on the VPS where this repo lives (e.g., /opt/lt4c-backend). |
DOCKERHUB_USERNAME– Docker Hub namespace that ownslt4c-backend.DOCKERHUB_TOKEN– Docker Hub access token/password with read/write permission (used fordocker login+ Cosign signing target).SSH_DEPLOY_HOST– Public hostname/IP of the VPS targeted byssh-deploy.yml.SSH_DEPLOY_USER– SSH user (for example, thelt4csystem user created bybootstrap-vps.sh).SSH_DEPLOY_PRIVATE_KEY– Private key paired with the VPSauthorized_keysentry for that user.SSH_DEPLOY_PORT– Optional TCP port override (defaults to22when omitted).
Grant the repository Environments named dev, staging, prod and map each to the matching AWS role (Actions will pick matrix.environment).
- Bootstrap remote state (
infra/bootstrap) with the shared S3 bucket and DynamoDB table. - Apply the
devstack (withcreate_github_oidc_provider=true) so the GitHub OIDC provider and CI/external-secrets roles exist. - Apply staging/prod stacks, passing the provider ARN and wiring each backend to the same bucket/table while feeding the outputs (
cluster_name,ci_role_arn,external_secrets_role_arn, etc.) back into GitHub. - Populate AWS Secrets Manager entries for
/lt4c/<env>/ACCESS_TOKEN_SECRET,/lt4c/<env>/REFRESH_TOKEN_SECRET,/lt4c/<env>/DISCORD_CLIENT_ID,/lt4c/<env>/DISCORD_CLIENT_SECRET, and/lt4c/<env>/DATABASE_URL. - Apply the Kubernetes bootstrap manifests once per cluster (
kubectl apply -f deploy/k8s-bootstrap/namespace-app.yaml,.../external-secrets.yaml). - Trigger the
deployworkflow fordev(or runhelm upgradewithvalues.k3s.yaml), verify/healthresponds, then promote the signed digest to staging/prod via tagged pushes and manual approvals as needed.
- No secrets live in git. Everything comes from AWS Secrets Manager via External Secrets.
- JWT secrets must be rotated regularly; rotate refresh tokens first, then access tokens, followed by a pod restart (
kubectl rollout restart deploy/app -n <ns>). - All IAM roles follow least privilege with IRSA + GitHub OIDC; never use static AWS keys in GitHub.
- Helm chart enforces: runAsNonRoot, readOnlyRootFS, dropped capabilities, resource requests/limits, probes on
/health, NetworkPolicy default deny, PDB, configurable HPA, and migration Jobs executed outside the web container. - Pod logs are structured JSON to stdout; integrate with Loki/ELK. Metrics/traces ready for Prometheus/OpenTelemetry (add ServiceMonitor/collector as needed).
- Merge PR → CI builds/signed image (
sha-<commit>tag). - Run
Deploytargetingdev. Validate behaviour + smoke tests. - Create annotated tag
vX.Y.Z→ staging deploy auto-runs. Monitor. - Approve
prodGitHub Environment, runDeploywith the same digest. Document Helm revision + Actions run ID in the change record. - If a regression is detected, use
helm rollback app <revision> -n lt4c-<env>and re-run the migration job if needed (documented inRunbook.md).
For detailed operational procedures (rollbacks, secret rotations, incident flow), see Runbook.md.