Skip to content

Long-running service

A long-running service is a Deployment that runs indefinitely, backed by a Service for in-cluster routing and an Ingress for external HTTPS access. This is the standard shape for web apps, APIs, and any workload that serves traffic continuously.

Before using this recipe, confirm:

  • Your tenant is provisioned and you have completed Your first deployment end-to-end.
  • Your workload repo is set up per Your repo, your workloads — ArgoCD is pointed at your repo and syncing.
  • You know your tenant name (the <your-tenant> prefix used in namespace names).

Use this recipe when your workload:

  • Runs continuously (does not exit on its own).
  • Serves HTTP/HTTPS traffic from outside the cluster.
  • Needs a stable hostname like <app>.<your-tenant>.kestrel.arbutus.cloud.

If your workload runs to completion and exits, see Jobs & CronJobs. If you need a short-lived interactive environment, see Dev pod.

All manifests go in your workload repo under a directory that your ArgoCD Application’s spec.source.path points at (e.g. manifests/my-service/). Replace every <your-tenant> placeholder with your real tenant name.

The minimal variant ships one replica with no probes, no anti-affinity, and uber-user-significant priority. Good for a first deploy or a low-stakes internal service.

manifests/my-service/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-service
namespace: <your-tenant>-prod
spec:
replicas: 1
selector:
matchLabels:
app: my-service
template:
metadata:
labels:
app: my-service
spec:
priorityClassName: uber-user-significant
securityContext:
runAsNonRoot: true
runAsUser: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginxinc/nginx-unprivileged:1.27-alpine
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /var/cache/nginx
- name: run
mountPath: /var/run
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
- name: run
emptyDir: {}

The Service is ClusterIPLoadBalancer and NodePort are blocked on Kestrel (see Known limitations). The Ingress uses ingressClassName: traefik and a tenant-scoped hostname.

manifests/my-service/service.yaml
apiVersion: v1
kind: Service
metadata:
name: my-service
namespace: <your-tenant>-prod
spec:
type: ClusterIP
selector:
app: my-service
ports:
- port: 80
targetPort: 8080
---
# manifests/my-service/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-service
namespace: <your-tenant>-prod
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- my-service.<your-tenant>.kestrel.arbutus.cloud
secretName: my-service-tls
rules:
- host: my-service.<your-tenant>.kestrel.arbutus.cloud
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 80

Replace my-service in the hostname with your application name. The resulting URL is https://my-service.<your-tenant>.kestrel.arbutus.cloud/. TLS is handled by cert-manager and Let’s Encrypt production — no manual certificate management needed.

Before committing, validate your manifests locally:

Terminal window
kubectl apply --dry-run=client -f manifests/my-service/deployment.yaml
kubectl apply --dry-run=client -f manifests/my-service/service.yaml
kubectl apply --dry-run=client -f manifests/my-service/ingress.yaml

All three should render without errors. This catches YAML syntax issues and missing required fields before ArgoCD tries to apply them.

After pushing to your repo and letting ArgoCD sync:

Terminal window
kubectl get pods -n <your-tenant>-prod
kubectl get svc -n <your-tenant>-prod
kubectl get ingress -n <your-tenant>-prod
kubectl logs -n <your-tenant>-prod deployment/my-service

Expected:

  • Pod(s) are Running (2 pods for the recommended variant, 1 for minimal).
  • The Service shows ClusterIP with port 80 → 8080.
  • The Ingress shows my-service.<your-tenant>.kestrel.arbutus.cloud as the hostname.
  • Logs show your application’s startup messages.

Open https://my-service.<your-tenant>.kestrel.arbutus.cloud/ in a browser. For the nginx example, you should see the default nginx welcome page with a valid TLS certificate.

  • Pod stuck in Pending. Check kubectl describe pod -n <your-tenant>-prod <pod-name> — the Events section shows the reason. Common causes: quota exceeded (request more via RCS ticket), or no node matches the anti-affinity constraint (reduce replicas to 1 temporarily).
  • Pod in CrashLoopBackOff with Kyverno admission error. You modified the securityContext block. Revert to the verbatim YAML above. See Known limitations for the full PSS rule.
  • Namespace rejected with forceTenantPrefix. The namespace name does not start with your tenant name. All namespaces must be <your-tenant>-<suffix>. See Known limitations.
  • Ingress returns 404 or certificate warning. cert-manager takes ~30 seconds to issue the first certificate. Wait and retry. If it persists, check kubectl describe ingress -n <your-tenant>-prod my-service and kubectl describe certificate -n <your-tenant>-prod my-service-tls.
  • ArgoCD shows OutOfSync but no errors. ArgoCD polls on a 3-minute interval. Click Sync in the ArgoCD UI for an immediate reconcile.
  • Need to make a temporary change outside GitOps? Use the escape hatch workflow — but remember that ArgoCD reverts any mutation on the next sync.