Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 300 additions & 0 deletions .github/workflows/deploy_eks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
name: Deploy to EKS

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

concurrency:
group: deploy-eks-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

env:
AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }}
EKS_CLUSTER_NAME: ${{ vars.EKS_CLUSTER_NAME || 'stathub-eks' }}
K8S_NAMESPACE: stathub
ECR_SERVER_REPO: stathub-server
ECR_CLIENT_REPO: stathub-client

Comment thread
coderabbitai[bot] marked this conversation as resolved.
jobs:
# ----------------------------------------------------------------------
# Phase 1 - Run unit, integration and e2e tests for client and server.
# ----------------------------------------------------------------------
test:
name: Phase 1 - Tests
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6

- name: Install pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24.14.0
cache: pnpm

- name: Install server dependencies
working-directory: server
run: pnpm install --frozen-lockfile

- name: Run server tests with coverage
working-directory: server
run: pnpm run test:ci

- name: Install client dependencies
working-directory: client
run: pnpm install --frozen-lockfile

- name: Run client tests with coverage
working-directory: client
run: pnpm run test:ci

- name: Upload server test report
if: always()
uses: actions/upload-artifact@v4
with:
name: server-test-report
path: |
server/junit.xml
server/coverage/
if-no-files-found: ignore

- name: Upload client test report
if: always()
uses: actions/upload-artifact@v4
with:
name: client-test-report
path: |
client/junit.xml
client/coverage/
if-no-files-found: ignore

# ----------------------------------------------------------------------
# Phase 2 - Provision the S3 artifact bucket and ECR repos with Terraform.
# Skipped on pull requests so PRs don't mutate cloud infra.
# ----------------------------------------------------------------------
terraform:
name: Phase 2 - Terraform Apply
needs: test
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
outputs:
ecr_server_url: ${{ steps.tf_outputs.outputs.ecr_server_url }}
ecr_client_url: ${{ steps.tf_outputs.outputs.ecr_client_url }}
bucket_name: ${{ steps.tf_outputs.outputs.bucket_name }}
defaults:
run:
working-directory: infra/terraform
steps:
- name: Checkout repo
uses: actions/checkout@v6

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
aws-region: ${{ env.AWS_REGION }}

- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.10.5

# Remote backend uses an S3 bucket bootstrapped out of band (see
# DEPLOY_EKS.md "State backend bootstrap"). The bucket name is held
# in the TF_STATE_BUCKET repo variable so it can be rotated without
# editing Terraform source.
- name: Terraform Init
env:
TF_STATE_BUCKET: ${{ vars.TF_STATE_BUCKET }}
run: |
if [ -z "${TF_STATE_BUCKET}" ]; then
echo "::error::Repo variable TF_STATE_BUCKET is not set. See DEPLOY_EKS.md."
exit 1
fi
terraform init -input=false \
-backend-config="bucket=${TF_STATE_BUCKET}" \
-backend-config="key=stathub/${{ env.AWS_REGION }}/terraform.tfstate" \
-backend-config="region=${{ env.AWS_REGION }}"

- name: Terraform Validate
run: terraform validate

- name: Terraform Plan
run: terraform plan -input=false -out=tfplan

- name: Terraform Apply
run: terraform apply -input=false -auto-approve tfplan
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Capture outputs
id: tf_outputs
run: |
echo "ecr_server_url=$(terraform output -raw ecr_server_repository_url)" >> "$GITHUB_OUTPUT"
echo "ecr_client_url=$(terraform output -raw ecr_client_repository_url)" >> "$GITHUB_OUTPUT"
echo "bucket_name=$(terraform output -raw artifacts_bucket_name)" >> "$GITHUB_OUTPUT"

# ----------------------------------------------------------------------
# Phase 3 - Build server and client images and push them to ECR.
# ----------------------------------------------------------------------
build_and_push:
name: Phase 3 - Build & Push Images
needs: terraform
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
outputs:
server_image: ${{ steps.tags.outputs.server_image }}
client_image: ${{ steps.tags.outputs.client_image }}
steps:
- name: Checkout repo
uses: actions/checkout@v6

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
aws-region: ${{ env.AWS_REGION }}

- name: Login to Amazon ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Compute image tags
id: tags
run: |
SHA="${GITHUB_SHA::7}"
echo "server_image=${{ needs.terraform.outputs.ecr_server_url }}:${SHA}" >> "$GITHUB_OUTPUT"
echo "client_image=${{ needs.terraform.outputs.ecr_client_url }}:${SHA}" >> "$GITHUB_OUTPUT"

- name: Build and push server image
uses: docker/build-push-action@v6
with:
context: ./server
push: true
tags: |
${{ steps.tags.outputs.server_image }}
${{ needs.terraform.outputs.ecr_server_url }}:latest
cache-from: type=gha,scope=server
cache-to: type=gha,mode=max,scope=server

- name: Build and push client image
uses: docker/build-push-action@v6
with:
context: ./client
push: true
tags: |
${{ steps.tags.outputs.client_image }}
${{ needs.terraform.outputs.ecr_client_url }}:latest
build-args: |
VITE_API_BASE_URL=${{ vars.VITE_API_BASE_URL }}
VITE_GOOGLE_CLIENT_ID=${{ secrets.VITE_GOOGLE_CLIENT_ID }}
cache-from: type=gha,scope=client
cache-to: type=gha,mode=max,scope=client

# ----------------------------------------------------------------------
# Phase 4 - Deploy to EKS. Renders manifests with the freshly built image
# tags and applies them. The cluster itself is provisioned out of band
# (see DEPLOY_EKS.md for the eksctl steps).
# ----------------------------------------------------------------------
deploy_eks:
name: Phase 4 - Deploy to EKS
needs: build_and_push
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
aws-region: ${{ env.AWS_REGION }}

- name: Install kubectl
uses: azure/setup-kubectl@v4
with:
version: v1.30.0

- name: Update kubeconfig for EKS cluster
run: |
aws eks update-kubeconfig \
--region "${AWS_REGION}" \
--name "${EKS_CLUSTER_NAME}"

- name: Verify cluster connectivity
run: |
kubectl version --client
kubectl get nodes

- name: Apply namespace
run: kubectl apply -f infra/k8s/namespace.yaml

- name: Create or update server Secret
env:
DATABASE_URL: ${{ secrets.SERVER_ENV_DATABASE_URL }}
JWT_SECRET: ${{ secrets.SERVER_ENV_JWT_SECRET }}
ARCJET_KEY: ${{ secrets.SERVER_ENV_ARCJET_KEY }}
ARCJET_MODE: ${{ secrets.SERVER_ENV_ARCJET_MODE }}
RESEND_API_KEY: ${{ secrets.SERVER_ENV_RESEND_API_KEY }}
GOOGLE_CLIENT_ID: ${{ secrets.SERVER_ENV_GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.SERVER_ENV_GOOGLE_CLIENT_SECRET }}
CLIENT_URL: ${{ vars.SERVER_ENV_CLIENT_URL }}
CLIENT_PASSWORD_RESET_URL: ${{ vars.SERVER_ENV_CLIENT_URL }}${{ vars.SERVER_ENV_CLIENT_PASSWORD_RESET_ENDPOINT }}
run: |
kubectl -n "${K8S_NAMESPACE}" create secret generic stathub-server-secrets \
--from-literal=DATABASE_URL="${DATABASE_URL}" \
--from-literal=JWT_SECRET="${JWT_SECRET}" \
--from-literal=ARCJET_KEY="${ARCJET_KEY}" \
--from-literal=ARCJET_MODE="${ARCJET_MODE}" \
--from-literal=RESEND_API_KEY="${RESEND_API_KEY}" \
--from-literal=GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}" \
--from-literal=GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}" \
--from-literal=CLIENT_URL="${CLIENT_URL}" \
--from-literal=CLIENT_PASSWORD_RESET_URL="${CLIENT_PASSWORD_RESET_URL}" \
--dry-run=client -o yaml | kubectl apply -f -

- name: Render and apply server manifests
env:
SERVER_IMAGE: ${{ needs.build_and_push.outputs.server_image }}
run: |
sed "s|SERVER_IMAGE_PLACEHOLDER|${SERVER_IMAGE}|g" infra/k8s/server-deployment.yaml | kubectl apply -f -
kubectl apply -f infra/k8s/server-service.yaml
kubectl apply -f infra/k8s/server-service-lb.yaml

- name: Render and apply client manifests
env:
CLIENT_IMAGE: ${{ needs.build_and_push.outputs.client_image }}
run: |
sed "s|CLIENT_IMAGE_PLACEHOLDER|${CLIENT_IMAGE}|g" infra/k8s/client-deployment.yaml | kubectl apply -f -
kubectl apply -f infra/k8s/client-service.yaml

- name: Wait for server rollout
run: kubectl -n "${K8S_NAMESPACE}" rollout status deployment/stathub-server --timeout=5m

- name: Wait for client rollout
run: kubectl -n "${K8S_NAMESPACE}" rollout status deployment/stathub-client --timeout=5m

- name: Print service endpoints
run: |
echo "Pods:"
kubectl -n "${K8S_NAMESPACE}" get pods -o wide
echo ""
echo "Services:"
kubectl -n "${K8S_NAMESPACE}" get svc
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
junit.xml

# nyc test coverage
.nyc_output
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
36 changes: 17 additions & 19 deletions client/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,36 +1,34 @@
# Stage 1: Build Step
FROM node:24.14.0-alpine AS build-step

# Set work directory
WORKDIR /usr/src/app

# Copy package files and install dependencies
COPY package.json ./
COPY pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml ./

# Install dependencies efficiently
RUN corepack enable
RUN corepack enable && pnpm install --frozen-lockfile

RUN pnpm install --frozen-lockfile

# Copy the rest of the application
COPY . .

# Set environment variables
ARG VITE_API_BASE_URL
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID

# Build the application
RUN pnpm run build

# Stage 2: Nginx Server
FROM nginx:1.28.2-alpine
# nginx-unprivileged runs as the non-root "nginx" user (uid 101) by default
# and listens on 8080 instead of 80, which avoids needing CAP_NET_BIND_SERVICE.
FROM nginxinc/nginx-unprivileged:1.28-alpine

USER root
RUN apk add --no-cache wget
USER nginx

COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf
COPY --from=build-step --chown=nginx:nginx /usr/src/app/dist /usr/share/nginx/html

# Copy the Nginx config file
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 8080

# Copy built application from build-step
COPY --from=build-step /usr/src/app/dist /usr/share/nginx/html
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --quiet --spider http://127.0.0.1:8080/healthz || exit 1

# Start Nginx
CMD ["nginx", "-g", "daemon off;"]
Loading
Loading