Deploy Spring Boot + React
Run a three-tier application — React SPA, Spring Boot API, Postgres — on Rune with one cast.
This recipe deploys a complete full-stack application: a React SPA served by nginx, a Spring Boot API behind it, and Postgres with a persistent volume. One castfile, one rune cast.
The full working source (Spring Boot app, React app, Dockerfiles) lives in runestack/rune-examples/fullstack-spring-react. This page walks through the Rune side — which is deliberately the small part.
┌────────────────── Rune (namespace: demo) ──────────────────┐
│ │
browser ──────▶ │ web (nginx + React) ──/api/*──▶ api (Spring) ─▶ postgres│
notes.local │ expose: notes.local ×2 replicas + volume │
│ │
└────────────────────────────────────────────────────────────┘
What you need
- A running
runedand configured CLI (quick start) —--dev-modeon a laptop is fine. - Docker, to build the two images locally:
git clone https://github.com/runestack/rune-examples
cd rune-examples/fullstack-spring-react
docker build -t notes-api:dev ./api
docker build -t notes-web:dev ./web
1. The database, with state that survives
Postgres gets a per-replica volume via claimTemplate, and its credentials come from a Rune secret rather than spec literals:
---
secret:
name: db-credentials
namespace: demo
type: static
data:
username: notes
password: change-me-please
---
service:
name: postgres
namespace: demo
image: postgres:16-alpine
scale: 1
ports:
- name: pg
port: 5432
dependencies:
- secret:db-credentials
env:
POSTGRES_DB: notes
POSTGRES_USER: "{{ secret: db-credentials/username }}"
POSTGRES_PASSWORD: "{{ secret: db-credentials/password }}"
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- name: pgdata
mountPath: /var/lib/postgresql/data
claimTemplate:
size: 5Gi
accessMode: ReadWriteOnce
health:
liveness:
type: tcp
port: 5432
initialDelaySeconds: 5
intervalSeconds: 10
Two details worth stealing for any database on Rune:
PGDATApoints at a subdirectory. A fresh volume mount containslost+found, andinitdbrefuses a non-empty directory.- The TCP probe keeps the reconciler honest —
apiwon't roll out until Postgres actually accepts connections.
2. The Spring Boot API
Spring binds SPRING_DATASOURCE_* environment variables natively, so wiring it to Postgres is three env lines — and the hostname is Rune's embedded DNS (<service>.<namespace>.rune):
---
service:
name: api
namespace: demo
image: notes-api:dev
scale: 2
ports:
- name: http
port: 8080
dependencies:
- service: postgres
- secret:db-credentials
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres.demo.rune:5432/notes
SPRING_DATASOURCE_USERNAME: "{{ secret: db-credentials/username }}"
SPRING_DATASOURCE_PASSWORD: "{{ secret: db-credentials/password }}"
health:
liveness:
type: http
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 20
intervalSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readiness:
type: http
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
intervalSeconds: 5
The probes hit Spring Actuator's built-in liveness/readiness groups (management.endpoint.health.probes.enabled: true in application.yaml). Give the JVM a generous initialDelaySeconds — 20 seconds here — so Rune doesn't kill instances that are merely booting.
3. The React frontend
The SPA is static files behind nginx; the only interesting line is in nginx.conf, where nginx proxies API calls across the cluster:
location /api/ {
proxy_pass http://api.demo.rune:8080;
}
The browser only ever talks to web — no CORS, no API hostname baked into the JS bundle. The service itself is plain, plus an expose block to reach it from outside:
---
service:
name: web
namespace: demo
image: notes-web:dev
scale: 1
ports:
- name: http
port: 80
dependencies:
- service: api
health:
liveness:
type: http
path: /healthz
port: 80
initialDelaySeconds: 3
intervalSeconds: 10
expose:
port: http
host: notes.local
On an edge node with a real domain, add tls: { auto: true } under expose and Rune provisions a certificate — see Expose a service.
4. Cast it
All four resources live in one file, so:
rune lint deploy/app.yaml
rune cast deploy/app.yaml
rune get services -n demo
NAME TYPE STATUS INSTANCES EXTERNAL GENERATION AGE
postgres container Running 1/1 - 1 1m
api container Running 2/2 - 1 1m
web container Running 1/1 notes.local 1 1m
Dependencies order the rollout: postgres → api → web. Then:
echo "127.0.0.1 notes.local" | sudo tee -a /etc/hosts
curl -X POST http://notes.local/api/notes \
-H 'Content-Type: application/json' -d '{"text": "deployed on rune"}'
open http://notes.local
In dev mode the ingress binds :8080, so use http://notes.local:8080.
5. Day two
rune logs api -n demo -f
rune exec postgres -n demo -- psql -U notes -c 'select * from note;'
rune scale api 4 -n demo
rune restart postgres -n demo # data survives — the volume rebinds
When the stack outgrows one file, the same example ships a runeset variant with templated image tags, replica counts, and per-environment values — install it as a release with rune cast ./runeset --release notes.
Adapting this to your stack
The shape transfers directly: any HTTP framework slots into the api spec (change the image, port, and probe path), and any SPA or static site slots into web. Rails + Hotwire, Django + Vue, Axum + Svelte — the Rune side barely changes. More stacks: runestack/rune-examples.