Cloud / SRE
Deploying a New EKS Service: GitOps, Internal ALB, and Cloudflare DNS
A deployment record for a Spring Boot service on EKS, covering ECR, GitLab CI, ArgoCD, Ingress, an internal ALB, Route 53, and Cloudflare DNS.
On this page
- Terms used here
- Existing service boundary
- Step 1: Add the ECR Repository
- Step 2: Add the GitOps Deployment Directory
- Step 3: Reuse the Existing Backend Configuration
- Step 4: Update GitLab CI
- Step 5: Create the ArgoCD Application
- Step 6: Expose a New Host Through the ALB
- Step 7: The DNS Record Was Added in the Wrong Place
- Step 8: An Internal ALB Still Requires Internal Network Access
- Step 9: Separate 200, 401, and Network Failures
- Troubleshooting order
- Verified request path
- References
A Spring Boot service was healthy inside EKS, but callers still needed a private HTTPS path to it. The deployment crossed CI, GitOps, Service routing, an internal ALB, and DNS ownership. Each layer needed its own evidence before a successful health response could establish the complete path.[1]
The final health check looked like this:
https://app-api-dev.example.com/actuator/health/readiness
Response:
{"status":"UP"}
The checks follow the dependency path from the container to authoritative DNS. Configuration blocks are excerpts from this deployment, not complete manifests or a portable CI pipeline. Resolve placeholders, variable mappings, selectors, TLS, and identity configuration in the target stack before applying them.
Terms used here
| Term | Meaning |
|---|---|
| EKS | AWS managed Kubernetes. The service runs as Kubernetes workloads inside an EKS cluster. |
| ECR | AWS container image registry. CI pushes the new service image there before GitOps deploys it. |
| ArgoCD | GitOps controller that applies Kubernetes manifests from a Git repository into the cluster. |
| Ingress | Kubernetes routing rule for HTTP host/path traffic. Here it maps the new host to the app service. |
| Internal ALB | An AWS Application Load Balancer with private IPs only. It is reachable from networks that have a path into its VPC, not directly from the public internet.[2] |
| Cloudflare DNS | The authoritative DNS provider for the example domain in this note. DNS records had to be added there instead of only in Route53. |
Existing service boundary
The backend system already had several services:
| Service | Access pattern |
|---|---|
| Main API | Reached through a dev API domain |
| Workers | Internal to the cluster |
| New app service | Needed a separate dev API domain |
The new service was an independent Spring Boot module on port 8082. The main API calls it inside the cluster:
http://app-service:8082
So the first step was not DNS. The first step was to make the service run inside EKS with a stable Kubernetes Service name.
Step 1: Add the ECR Repository
CI needs somewhere to push the new image, so the dev ECR repository list gained one more repository:
ecr_repos = [
"dev-api",
"dev-app-service",
"dev-worker-core",
"dev-worker-growth"
]
Then run a plan against the dev ECR stack:
terragrunt plan -no-color
The useful result was:
Plan: 2 to add, 0 to change, 0 to destroy.
That proved the Terraform change was additive: one ECR repository and its lifecycle policy, with no changes to existing services.
After that, apply it:
terragrunt apply -auto-approve -no-color
Step 2: Add the GitOps Deployment Directory
The environment already used ArgoCD and Kustomize, so the new service followed the same layout:
dev/app-service/
application.yaml
external-secret.yaml
k8s-app-service-dev.yaml
kustomization.yaml
The Deployment had to match the service port:
containers:
- name: app-service
image: <ecr>/dev-app-service:dev-REPLACE_ME
ports:
- containerPort: 8082
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8082
The Kubernetes Service kept the same DNS name the main API expected:
apiVersion: v1
kind: Service
metadata:
name: app-service
spec:
ports:
- port: 8082
targetPort: 8082
Do not casually expose the Service as port 80 here. The application configuration calls:
http://app-service:8082
If the Service only exposes 80, the in-cluster call will fail even though the Pod is running.
Step 3: Reuse the Existing Backend Configuration
The first idea was to create a separate Secrets Manager entry for the new service:
dev/app-service/application-secrets.yaml
After checking the Java configuration, the better choice was to reuse the same runtime configuration used by the main API. The ExternalSecret still creates a separate Kubernetes Secret, but its remoteRef points to the existing backend configuration:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: app-service-secrets
spec:
target:
name: app-service-secrets
data:
- secretKey: application.yaml
remoteRef:
key: dev/api/application-secrets.yaml
The app service reads the same database variables:
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
This avoids duplicating environment-specific configuration and reduces the chance that the API and the new service drift apart.
Step 4: Update GitLab CI
The new module existed in the code repository, but CI only built the main API and worker images. Three updates were needed:
- Add an app service ECR repository variable.
- Add Docker build and push steps.
- Update the GitOps image tag for
dev/app-service/kustomization.yaml.
The CI change looked like this:
variables:
DEV_ECR_APP_SERVICE_REPO: "dev-app-service"
build_push:
script:
- docker build -f app-service-app/Dockerfile \
-t "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$APP_SERVICE_REPO:$TAG" .
- docker push "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$APP_SERVICE_REPO:$TAG"
The GitOps tag update also had to include the new path:
update_newtag(
f"{prefix}/app-service/kustomization.yaml",
os.environ["APP_SERVICE_IMAGE"],
tag
)
Without this, the ECR repository and Kubernetes manifests can be correct, but no new image will actually be deployed.
Step 5: Create the ArgoCD Application
The app-of-apps file got one more ArgoCD Application:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: dev-app-service
spec:
source:
repoURL: <gitops-repo>
targetRevision: HEAD
path: dev/app-service
destination:
server: https://kubernetes.default.svc
namespace: dev
syncPolicy:
automated:
prune: true
selfHeal: true
After applying it, check ArgoCD and Kubernetes:
kubectl -n argocd get application dev-app-service
kubectl -n dev get deploy,svc,pod -l app=app-service -o wide
The expected state:
dev-app-service Synced Healthy
deployment/app-service 1/1
pod/app-service-... Running
service/app-service 8082/TCP
Also check ExternalSecret:
kubectl -n dev get externalsecret app-service-secrets
Expected:
SecretSynced True
At this point the service is working inside the cluster.
Step 6: Expose a New Host Through the ALB
The existing dev API already used an internal ALB. The new service was added to the same Ingress with a new host rule:
rules:
- host: api-dev.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 80
- host: app-api-dev.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app-service
port:
number: 8082
After applying it:
kubectl -n dev describe ingress dev-apps
The Rules section should include:
app-api-dev.example.com
/ app-service:8082 (<pod-ip>:8082)
Then check the ALB target group:
aws elbv2 describe-target-health \
--target-group-arn <app-service-target-group-arn>
Expected:
State: healthy
This confirmed the Ingress rule, ALB listener rule, target group, and Pod readiness.
Step 7: The DNS Record Was Added in the Wrong Place
DNS was the easy part to misread.
At first, a record was added in Route53:
app-api-dev.example.com CNAME <internal-alb-dns-name>
But local access still failed:
Could not resolve host
Check the name:
dig +short app-api-dev.example.com
No result.
At that point, do not keep debugging ALB or Kubernetes. Check the authoritative name servers for the root domain:
dig +short NS example.com
The result showed that the domain was delegated to Cloudflare, not Route53.
In practice, a same-name hosted zone in Route53 does not necessarily control the real domain. Public DNS follows the authoritative NS delegation.
The correct fix was to add the record in Cloudflare:
Type: CNAME
Name: app-api-dev
Target: <internal-alb-dns-name>
Proxy status: DNS only
The record was kept DNS only. This setup had no Cloudflare Tunnel or other private-network integration that could carry proxy traffic into the VPC.
Step 8: An Internal ALB Still Requires Internal Network Access
After DNS was fixed:
dig +short app-api-dev.example.com
The result looked like:
<internal-alb-dns-name>.
10.x.x.x
10.x.x.x
Seeing 10.x.x.x is expected because an internal load balancer resolves to private addresses.[2]
Callers still need VPN, VPC access, or another network path to those private addresses. DNS resolution alone does not provide access to the VPC.[2]
Step 9: Separate 200, 401, and Network Failures
Final health check:
curl -i https://app-api-dev.example.com/actuator/health/readiness
Response:
HTTP/2 200
Body:
{"status":"UP"}
Root path:
curl -i https://app-api-dev.example.com/
Response:
HTTP/2 401
In this request path, the response showed that authentication was the next boundary to investigate. A 401 alone does not prove that every downstream service is healthy.
| Symptom | Meaning |
|---|---|
Could not resolve host | DNS is not resolving |
Connection timed out | Network path to the internal ALB is unavailable |
502 | ALB cannot reach the backend target, or the app is failing |
401 | Request reached the app, but authentication is required |
/actuator/health/readiness returns UP | Backend readiness is healthy |
Troubleshooting order
Do not start with browser symptoms and guess. A better order is:
- Check Pod, Service, and ExternalSecret.
- Check whether Ingress generated the ALB rule.
- Check whether the target group is healthy.
- Check whether the certificate covers the new host.
- Check the authoritative DNS and the actual record.
When Route53 and Cloudflare both exist, always confirm the authoritative DNS first. A record visible in Route53 is irrelevant if the domain is delegated to Cloudflare.
Verified request path
The working path became:
browser
-> Cloudflare DNS (DNS only)
-> internal ALB
-> Kubernetes Ingress host rule
-> app-service Service:8082
-> app-service Pod:8082
Health check:
{"status":"UP"}
At that point the backend path is working. If a business endpoint returns 401, the next investigation should be authentication, tokens, or authorization scope, not ALB or DNS.
References
[1] Amazon Web Services, “Route application and HTTP traffic with Application Load Balancers.”
[2] Amazon Web Services, “How Elastic Load Balancing works.”