Spring Boot + AWS EKS
A Spring Boot app deployed to AWS EKS (Kubernetes) with vsync-s3-client reading secrets from S3 at boot. The two env vars come from a Kubernetes Secret mounted as env vars, populated via the AWS Secrets Manager Secrets Store CSI Driver.
Stack
- App: Spring Boot 3.3 + Java 21
- Platform: AWS EKS (Kubernetes)
- Secret store: AWS Secrets Manager → Kubernetes Secret (via Secrets Store CSI Driver)
- Vault: AWS S3
- Lib:
io.github.muthuishere:vsync-s3-client:0.11.0
Working directory tree
my-spring-app/
├── src/main/java/com/example/myapp/
│ ├── MyAppApplication.java
│ ├── config/
│ │ ├── VsyncConfig.java
│ │ └── DataSourceConfig.java
│ └── web/
│ └── HealthController.java
├── infra/
│ └── vault/ (gitignored)
│ └── prod/
│ └── .env.prod
├── infra/k8s/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── secret-provider-class.yaml (CSI driver config)
│ └── service-account.yaml (IRSA — IAM Roles for Service Accounts)
├── Dockerfile
├── pom.xml
└── .gitignoreOne-time setup
1. Push vault, mint token
vsync runtime-token --env=prod \
--access-key=AKIA_PROD_READONLY \
--secret-key=PROD_READONLY_SECRET \
> /tmp/vsync-config-blob2. Upload to AWS Secrets Manager
aws secretsmanager create-secret \
--name myapp/prod/vsync-config \
--secret-string "file:///tmp/vsync-config-blob"
aws secretsmanager create-secret \
--name myapp/prod/vsync-passphrase \
--secret-string 'correct-horse-battery-staple'
shred -u /tmp/vsync-config-blob3. IRSA — IAM Role for Service Accounts
The EKS service account needs to read the Secrets Manager secrets.
# Create IAM policy
aws iam create-policy --policy-name myapp-vsync-secrets-read --policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": [
"arn:aws:secretsmanager:eu-central-1:123456789012:secret:myapp/prod/vsync-config-*",
"arn:aws:secretsmanager:eu-central-1:123456789012:secret:myapp/prod/vsync-passphrase-*"
]
}]
}'
# Create the IRSA mapping
eksctl create iamserviceaccount \
--cluster my-eks-cluster \
--namespace myapp \
--name myapp-sa \
--attach-policy-arn arn:aws:iam::123456789012:policy/myapp-vsync-secrets-read \
--approve4. Install the Secrets Store CSI Driver + AWS provider
(One-time per cluster.)
helm repo add secrets-store-csi-driver \
https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts
helm install -n kube-system csi-secrets-store \
secrets-store-csi-driver/secrets-store-csi-driver
kubectl apply -f https://raw.githubusercontent.com/aws/secrets-store-csi-driver-provider-aws/main/deployment/aws-provider-installer.yamlCode
pom.xml (excerpt)
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>io.github.muthuishere</groupId>
<artifactId>vsync-s3-client</artifactId>
<version>0.11.0</version>
</dependency>
</dependencies>MyAppApplication.java
package com.example.myapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MyAppApplication {
public static void main(String[] args) {
SpringApplication.run(MyAppApplication.class, args);
}
}config/VsyncConfig.java
package com.example.myapp.config;
import io.github.muthuishere.vsync.s3client.client.Vsync;
import io.github.muthuishere.vsync.s3client.client.VsyncClient;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class VsyncConfig {
private static final Logger log = LoggerFactory.getLogger(VsyncConfig.class);
private Vsync handle;
@Bean
public Vsync vsync() {
this.handle = VsyncClient.open();
log.info("vsync booted: gen={}, db source={}",
handle.generation(),
handle.envSource("DATABASE_URL"));
return this.handle;
}
@PreDestroy
public void shutdown() {
if (handle != null) {
handle.close();
}
}
}config/DataSourceConfig.java
package com.example.myapp.config;
import io.github.muthuishere.vsync.s3client.client.Vsync;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource(Vsync vsync) {
String url = vsync.getEnv("DATABASE_URL");
String user = vsync.getEnv("DATABASE_USER");
String password = vsync.getEnv("DATABASE_PASSWORD");
if (url == null || user == null || password == null) {
throw new IllegalStateException(
"DATABASE_URL / DATABASE_USER / DATABASE_PASSWORD must be set in vault");
}
return DataSourceBuilder.create()
.url(url)
.username(user)
.password(password)
.driverClassName("org.postgresql.Driver")
.build();
}
}web/HealthController.java
package com.example.myapp.web;
import io.github.muthuishere.vsync.s3client.client.Vsync;
import io.github.muthuishere.vsync.s3client.client.errors.ManifestNotFoundException;
import io.github.muthuishere.vsync.s3client.client.errors.S3UnreachableException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class HealthController {
private final Vsync vsync;
public HealthController(Vsync vsync) {
this.vsync = vsync;
}
@GetMapping("/healthz")
public Map<String, Object> healthz() {
try {
if (vsync.hasNewVersion()) {
return Map.of(
"status", "stale",
"localGen", vsync.generation(),
"remoteGen", vsync.remoteGeneration()
);
}
return Map.of("status", "fresh", "gen", vsync.generation());
} catch (S3UnreachableException | ManifestNotFoundException e) {
return Map.of("status", "unknown", "gen", vsync.generation());
}
}
}Deployment
Dockerfile
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build
COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .
RUN ./mvnw dependency:go-offline -B
COPY src/ src/
RUN ./mvnw package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-jar", "/app/app.jar"]infra/k8s/secret-provider-class.yaml
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: myapp-vsync-secrets
namespace: myapp
spec:
provider: aws
parameters:
objects: |
- objectName: "myapp/prod/vsync-config"
objectType: "secretsmanager"
objectAlias: "vsync-config"
- objectName: "myapp/prod/vsync-passphrase"
objectType: "secretsmanager"
objectAlias: "vsync-passphrase"
secretObjects:
- secretName: myapp-vsync-env
type: Opaque
data:
- objectName: vsync-config
key: VSYNC_CONFIG
- objectName: vsync-passphrase
key: VSYNC_PASSPHRASEThis tells the CSI driver to fetch from Secrets Manager and project into a Kubernetes Secret named myapp-vsync-env.
infra/k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: myapp
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
serviceAccountName: myapp-sa
containers:
- name: app
image: 123456789012.dkr.ecr.eu-central-1.amazonaws.com/myapp:latest
ports:
- containerPort: 8080
envFrom:
- secretRef:
name: myapp-vsync-env
resources:
requests: { cpu: 250m, memory: 512Mi }
limits: { cpu: 1000m, memory: 1Gi }
startupProbe:
httpGet: { path: /healthz, port: 8080 }
failureThreshold: 30 # 30 * 2s = 60s budget for vsync.open + Spring boot
periodSeconds: 2
readinessProbe:
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
failureThreshold: 3
volumeMounts:
- name: vsync-secrets
mountPath: /mnt/secrets
readOnly: true
volumes:
- name: vsync-secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: myapp-vsync-secretsNotes:
envFrom+secretRefpullsVSYNC_CONFIGandVSYNC_PASSPHRASEfrom the projected K8s Secret into env vars on the container.volumeMountsis required by the CSI driver — it must mount the volume to trigger the secret sync into the K8s Secret. The mount itself is unused; the side effect is the Secret population.startupProbefailureThreshold: 30 × period 2s = 60s budget. Spring Boot cold-starts in 8-15s on JDK 21 native (or 20-40s on JIT); vsyncopen()adds <1s. Adjust based on your timings.maxUnavailable: 1, maxSurge: 1does a careful rolling deploy: at most one pod down, at most one extra pod up at a time.
Deploy
kubectl apply -f infra/k8s/secret-provider-class.yaml
kubectl apply -f infra/k8s/deployment.yaml
kubectl apply -f infra/k8s/service.yamlAfter rotation — EKS specifics
Rotate the passphrase
# 1. On a laptop
vsync rotate-passphrase --env=prod
# 2. Update Secrets Manager
aws secretsmanager update-secret \
--secret-id myapp/prod/vsync-passphrase \
--secret-string 'new-passphrase'
# 3. Force pod re-roll — pods re-mount the CSI volume on restart,
# which re-fetches from Secrets Manager
kubectl rollout restart deployment/myapp -n myapp
# 4. Wait for rollout, verify
kubectl rollout status deployment/myapp -n myapp
kubectl exec -n myapp deploy/myapp -- wget -qO- http://localhost:8080/healthz
# → {"status":"fresh","gen":<new>}Important: the CSI driver re-fetches secrets when a pod restarts, not on a schedule. Running pods keep the old projected Secret value until they're recreated. The rolling restart is the propagation mechanism.
Auto-sync option
The CSI driver supports syncSecret.enabled=true and pod restarts on secret rotation — but it relies on detecting AWS Secrets Manager version changes. For the most predictable behaviour, stick with explicit kubectl rollout restart after rotation.
Things to watch out for
- JVM cold start. Spring Boot on JIT takes 20-40s to first request. vsync
open()adds ~200-500ms. ThestartupProbefailureThreshold needs to cover the JVM's startup, not just vsync's. GraalVM Native Image cuts cold-start dramatically if you can use it. @PreDestroyorder. Spring shuts down beans in reverse dependency order. TheVsyncConfig.shutdown()runs after any bean that depends on theVsynchandle, which meansDataSourceConfig's connection pool drains first, then vsync closes. This is correct.- JMX / Actuator exposing env vars. Spring Boot Actuator's
/actuator/envendpoint shows env vars by default. Disable in production or restrict via security —management.endpoint.env.enabled=falseinapplication.properties. - Pod logs catching env on crash. If your app does
Exception.getMessage()that includes env contents, or if your logger has a stack-trace formatter that dumps env, secrets land in CloudWatch. Audit your logging config. - Sidecar containers in the pod share the env vars (if
envFromis at the pod level, not container level — but it's at container level in the deployment above, so this is fine by default). - HPA / autoscaling. If you scale up, new pods do their own vsync
open()— one S3 round trip per new pod. At 100+ pods, that's 100 S3 reads. S3 handles this trivially, but if you're worried about cost or rate-limiting, scale slowly. - Image pull secrets vs. vsync. ECR auth is handled by the kubelet's IAM role, separate from vsync. Don't try to put ECR pull creds in the vault.
Local development
vsync pull dev
vsync use dev # ./.env → infra/vault/dev/.env.dev
# Run with Spring's default property loading (reads ./.env if spring-dotenv is configured,
# or use system properties via -Dspring.profiles.active=local + application-local.properties)
./mvnw spring-boot:runFor exercising the runtime lib in dev:
export VSYNC_CONFIG_FILE=$(mktemp)
export VSYNC_PASSPHRASE_FILE=$(mktemp)
chmod 0600 $VSYNC_CONFIG_FILE $VSYNC_PASSPHRASE_FILE
echo "$DEV_BLOB" > $VSYNC_CONFIG_FILE
echo "$DEV_PASSPHRASE" > $VSYNC_PASSPHRASE_FILE
./mvnw spring-boot:runWhere to go next
- Java lib reference: Java
- Mint a token: Runtime tokens
- Rotate the passphrase: Rotate-passphrase runbook
- Other AWS recipe: Go + ECS