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
85 changes: 39 additions & 46 deletions .github/workflows/deploy-k8s.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@ name: Deploy to Kubernetes

on:
push:
branches:
- main
branches: [main]
paths:
- 'backend/**'
- 'frontend/**'
- 'k8s/**'
- '.github/workflows/deploy-k8s.yml'
# Triggered by the backend/frontend repos when their code changes.
Expand Down Expand Up @@ -35,30 +32,21 @@ on:
- app-updated
workflow_dispatch:
inputs:
backend_ref:
description: 'Git ref of SynapsVault/backend to build'
environment:
description: 'Target overlay (staging or production)'
required: true
default: 'main'
type: string
frontend_ref:
description: 'Git ref of SynapsVault/frontend to build'
required: true
default: 'main'
type: string

permissions:
contents: read

concurrency:
group: deploy-k8s
cancel-in-progress: false
default: 'staging'
type: choice
options:
- staging
- production

env:
REGISTRY: ghcr.io
KUSTOMIZE_VERSION: '5.4.3'

jobs:
build-and-push:
name: Build and Push Images
validate:
name: Validate Kustomize overlays
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -68,9 +56,10 @@ jobs:
image-tag: ${{ steps.meta.outputs.tag }}
image-prefix: ${{ steps.meta.outputs.prefix }}
steps:
- name: Checkout deploy repository
uses: actions/checkout@v7
- name: Checkout
uses: actions/checkout@v4

- name: Install kustomize
# The Dockerfiles live here; the application source lives in sibling repos.
- name: Checkout backend source
uses: actions/checkout@v7
Expand All @@ -89,20 +78,24 @@ jobs:
- name: Compute image names
id: meta
run: |
# Registry paths must be lowercase (the org is "SynapsVault").
echo "prefix=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
echo "tag=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
curl -sSL "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv${KUSTOMIZE_VERSION}/kustomize_v${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C /usr/local/bin kustomize

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build base
run: kustomize build k8s/base > /dev/null

- name: Log in to the container registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build staging overlay
run: kustomize build k8s/overlays/staging > /dev/null

- name: Build production overlay
run: kustomize build k8s/overlays/production > /dev/null

deploy:
name: Deploy (${{ github.event.inputs.environment || 'staging' }})
needs: validate
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.environment || 'staging' }}
- name: Build and push backend image
id: build-backend
uses: docker/build-push-action@v7
Expand Down Expand Up @@ -293,14 +286,13 @@ jobs:
name: production
url: ${{ vars.DEPLOY_URL }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Checkout
uses: actions/checkout@v4

- name: Check cluster credentials
id: config
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG }}
- name: Install kustomize
run: |
curl -sSL "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv${KUSTOMIZE_VERSION}/kustomize_v${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C /usr/local/bin kustomize
if [[ -z "$KUBECONFIG_B64" ]]; then
echo "::warning::Deploy skipped: the KUBECONFIG secret is not configured for the production environment. Images were still built and pushed."
echo "ready=false" >> "$GITHUB_OUTPUT"
Expand All @@ -315,14 +307,15 @@ jobs:
version: v1.31.0

- name: Configure kubeconfig
if: steps.config.outputs.ready == 'true'
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG }}
run: |
mkdir -p "$HOME/.kube"
printf '%s' "$KUBECONFIG_B64" | base64 -d > "$HOME/.kube/config"
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > "$HOME/.kube/config"
chmod 600 "$HOME/.kube/config"

- name: Deploy overlay
run: |
OVERLAY="${{ github.event.inputs.environment || 'staging' }}"
kustomize build "k8s/overlays/${OVERLAY}" | kubectl apply -f -
- name: Pin image tags
if: steps.config.outputs.ready == 'true'
working-directory: ./k8s/overlays/production
Expand Down
34 changes: 34 additions & 0 deletions docs/DEPLOY-KUBERNETES.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,46 @@ Everything lives in the `synapsvault` namespace (set by `k8s/kustomization.yaml`
| `Service/backend`, `HorizontalPodAutoscaler/backend` | ClusterIP on port 3000; scales 2–10 replicas on CPU |
| `Deployment/frontend`, `Service/frontend` | nginx serving the built SPA on port 80 |
| `ConfigMap/backend-config` | Non-secret backend settings (network, RPC URLs, CORS origins) |
| `ClusterIssuer/letsencrypt-prod` | cert-manager ACME issuer (HTTP-01 via nginx) used by the ingress TLS annotation |
| `Ingress/app-api` | `https://<host>/api/*` → backend, with the `/api` prefix stripped |
| `Ingress/app-web` | `https://<host>/*` → frontend |

Before the first deploy, replace `app.example.com` in `k8s/ingress.yaml` and `ALLOWED_ORIGINS` in `k8s/configmap.yaml` with your real host.

Images are built from this repo's `backend/Dockerfile` and `frontend/Dockerfile` with the `SynapsVault/backend` and `SynapsVault/frontend` repositories as build contexts, and pushed to `ghcr.io/synapsvault/deploy/{backend,backend-migrate,frontend}`.

## 0. Install cert-manager and the ClusterIssuer

The ingress requests a certificate via the `cert-manager.io/cluster-issuer: letsencrypt-prod` annotation, so cert-manager and a matching `ClusterIssuer` must exist before the ingress is applied.

1. Install cert-manager (including its CRDs):

```sh
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
```

Wait for it to become ready:

```sh
kubectl wait --for=condition=Available deployment --all -n cert-manager --timeout=180s
```

2. Make sure an ingress controller (e.g. ingress-nginx) is installed and that your domain's DNS `A`/`CNAME` record points at the ingress load balancer address. HTTP-01 validation requires the domain to resolve to the ingress.

3. Edit `k8s/cluster-issuer.yaml` and set `spec.acme.email` to a real contact address (and, optionally, switch `spec.acme.server` to the Let's Encrypt staging URL while testing). Then apply it:

```sh
kubectl apply -f k8s/cluster-issuer.yaml
```

It is also part of the kustomization, so `kubectl apply -k k8s/` applies it too. Verify it is ready:

```sh
kubectl get clusterissuer letsencrypt-prod
```

The `READY` column should show `True` once the ACME account is registered.

## 1. Create the secrets

The backend reads its secrets from a Secret named `backend-secret`, which must exist before the workloads are applied. Create it out-of-band so real values are never committed. [`k8s/secrets.yaml`](../k8s/secrets.yaml) documents the expected keys; it is deliberately **not** part of the kustomization, so `kubectl apply -k` never overwrites the real secret with placeholders.
Expand Down Expand Up @@ -319,6 +352,7 @@ If the backend signing key (`BACKEND_SECRET_KEY` / `STELLAR_SECRET_KEY`) or the
## Troubleshooting

- **Pods stuck in `Pending`** — check node capacity and scheduling constraints (`kubectl describe pod <pod> -n synapsvault`).
- **Certificate not issued** — check `kubectl get certificate,order,challenge -n synapsvault` and `kubectl describe certificate <name> -n synapsvault`. Confirm the `ClusterIssuer` is `READY` and that the domain resolves to the ingress.
- **Pods in `CrashLoopBackOff`** — inspect logs (`kubectl logs <pod> -n synapsvault`) and confirm `backend-secret` exists with all required keys.
- **`ImagePullBackOff`** — verify the image tags and that the cluster can pull from `ghcr.io`.
- **Ingress returns 404/502** — confirm the ingress controller is running and the `Ingress` host matches your DNS.
Expand Down
2 changes: 2 additions & 0 deletions k8s/backend-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ spec:
labels:
app: backend
spec:
serviceAccountName: backend
automountServiceAccountToken: false
# Give Node enough time to drain connections and exit gracefully on SIGTERM.
terminationGracePeriodSeconds: 30
securityContext:
Expand Down
13 changes: 13 additions & 0 deletions k8s/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ data:
SUPABASE_URL: "https://your-project.supabase.co"
LOG_LEVEL: "info"
FACILITATOR_URL: "http://backend:3000"
# Comma-separated browser origins allowed by CORS. The placeholder below is
# replaced at deploy time from vars.DEPLOY_HOST via the kustomize
# replacements in k8s/kustomization.yaml (see also k8s/ingress.yaml).
ALLOWED_ORIGINS: "https://DEPLOY_HOST"
---
# Base defaults only. Environment-specific values (network, RPC/Horizon URLs,
# USDC contract, ALLOWED_ORIGINS) are overridden per overlay via
# kustomize patches in k8s/overlays/{staging,production}.
#
# ALLOWED_ORIGINS is parameterised: the DEPLOY_HOST token is substituted with
# the deploy host (vars.DEPLOY_HOST) by the kustomize replacements wired in
# k8s/kustomization.yaml, so host, TLS secret and CORS origin all derive from
# a single value.
# Comma-separated browser origins allowed by CORS; set to your public host.
ALLOWED_ORIGINS: "https://app.example.com"
# Contract IDs deployed to the network above. These are managed in Git so
Expand Down
39 changes: 26 additions & 13 deletions k8s/frontend-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: app
labels:
app: frontend
app.kubernetes.io/name: frontend
app.kubernetes.io/part-of: app
spec:
replicas: 2
replicas: 1
selector:
matchLabels:
app: frontend
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
app.kubernetes.io/name: frontend
template:
metadata:
labels:
app: frontend
app.kubernetes.io/name: frontend
app.kubernetes.io/part-of: app
spec:
serviceAccountName: frontend
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 101
Expand All @@ -27,10 +27,21 @@ spec:
type: RuntimeDefault
containers:
- name: frontend
image: frontend
imagePullPolicy: IfNotPresent
image: ghcr.io/example/frontend:latest
ports:
- name: http
containerPort: 3000
envFrom:
- configMapRef:
name: frontend-config
readinessProbe:
httpGet:
path: /
port: http
livenessProbe:
httpGet:
path: /
port: http
containerPort: 80
protocol: TCP
env:
Expand Down Expand Up @@ -85,9 +96,11 @@ spec:
failureThreshold: 3
resources:
requests:
cpu: "50m"
memory: "64Mi"
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
cpu: "250m"
memory: "128Mi"
volumes:
Expand Down
17 changes: 11 additions & 6 deletions k8s/ingress.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
# routes from the root, e.g. /resources, /publishers, /health)
# /* -> frontend SPA (which has its own /resources, /admin, ... pages)
# The frontend is built with VITE_API_URL=/api to match.
#
# The host and TLS secret are parameterised via Kustomize vars driven by
# DEPLOY_HOST (see k8s/kustomization.yaml). Set DEPLOY_HOST at deploy time
# (e.g. `kustomize build --env DEPLOY_HOST=app.example.com` or an overlay
# patch) and the host, TLS hosts and TLS secret name are all derived from it.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
Expand All @@ -26,10 +31,10 @@ spec:
ingressClassName: nginx
tls:
- hosts:
- app.example.com
secretName: app-tls
- $(DEPLOY_HOST)
secretName: $(DEPLOY_HOST)-tls
rules:
- host: app.example.com
- host: $(DEPLOY_HOST)
http:
paths:
- path: /api(/|$)(.*)
Expand All @@ -54,10 +59,10 @@ spec:
ingressClassName: nginx
tls:
- hosts:
- app.example.com
secretName: app-tls
- $(DEPLOY_HOST)
secretName: $(DEPLOY_HOST)-tls
rules:
- host: app.example.com
- host: $(DEPLOY_HOST)
http:
paths:
- path: /
Expand Down
31 changes: 31 additions & 0 deletions k8s/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ namespace: synapsvault
resources:
- namespace.yaml
- configmap.yaml
- backend-serviceaccount.yaml
- backend-migrate-job.yaml
- backend-deployment.yaml
- backend-service.yaml
- frontend-serviceaccount.yaml
- frontend-deployment.yaml
- pdb.yaml
- ingress.yaml
- clusterissuer.yaml

labels:
- pairs:
Expand Down Expand Up @@ -47,6 +50,34 @@ images:
newName: ghcr.io/synapsvault/deploy/frontend
newTag: latest

# Single deploy-time variable driving the ingress host, TLS secret and
# ALLOWED_ORIGINS. Override with `kustomize edit set var DEPLOY_HOST=...`
# (or `--env-file`) before `kubectl apply -k`.
vars:
- name: DEPLOY_HOST
objref:
apiVersion: v1
kind: ConfigMap
name: synapsvault-config

replacements:
- source:
kind: ConfigMap
name: synapsvault-config
fieldPath: data.DEPLOY_HOST
targets:
- select:
kind: Ingress
name: synapsvault
fieldPaths:
- spec.rules[0].host
- spec.tls[0].hosts[0]
- spec.tls[0].secretName
- select:
kind: ConfigMap
name: synapsvault-config
fieldPaths:
- data.ALLOWED_ORIGINS
# Staging and production are built from this base via the overlays in
# k8s/overlays/{staging,production}. Build them with:
# kustomize build k8s/overlays/staging
Expand Down