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.
Preconditions
Section titled “Preconditions”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 case
Section titled “Use case”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.
Deployment
Section titled “Deployment”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.
apiVersion: apps/v1kind: Deploymentmetadata: name: my-service namespace: <your-tenant>-prodspec: 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 recommended variant adds 2 replicas, liveness/readiness probes, pod anti-affinity (spread across nodes), and uber-user-preempt-high priority. Use this for production workloads backing tenant-facing Ingress routes.
apiVersion: apps/v1kind: Deploymentmetadata: name: my-service namespace: <your-tenant>-prodspec: replicas: 2 selector: matchLabels: app: my-service template: metadata: labels: app: my-service spec: priorityClassName: uber-user-preempt-high securityContext: runAsNonRoot: true runAsUser: 101 seccompProfile: type: RuntimeDefault affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: my-service topologyKey: kubernetes.io/hostname containers: - name: nginx image: nginxinc/nginx-unprivileged:1.27-alpine ports: - containerPort: 8080 livenessProbe: httpGet: path: / port: 8080 initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: / port: 8080 initialDelaySeconds: 3 periodSeconds: 5 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: {}Service and Ingress
Section titled “Service and Ingress”The Service is ClusterIP — LoadBalancer and NodePort are blocked on Kestrel (see Known limitations). The Ingress uses ingressClassName: traefik and a tenant-scoped hostname.
apiVersion: v1kind: Servicemetadata: name: my-service namespace: <your-tenant>-prodspec: type: ClusterIP selector: app: my-service ports: - port: 80 targetPort: 8080---# manifests/my-service/ingress.yamlapiVersion: networking.k8s.io/v1kind: Ingressmetadata: 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: 80Replace 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.
Dry-run
Section titled “Dry-run”Before committing, validate your manifests locally:
kubectl apply --dry-run=client -f manifests/my-service/deployment.yamlkubectl apply --dry-run=client -f manifests/my-service/service.yamlkubectl apply --dry-run=client -f manifests/my-service/ingress.yamlAll three should render without errors. This catches YAML syntax issues and missing required fields before ArgoCD tries to apply them.
Verify
Section titled “Verify”After pushing to your repo and letting ArgoCD sync:
kubectl get pods -n <your-tenant>-prodkubectl get svc -n <your-tenant>-prodkubectl get ingress -n <your-tenant>-prodkubectl logs -n <your-tenant>-prod deployment/my-serviceExpected:
- Pod(s) are
Running(2 pods for the recommended variant, 1 for minimal). - The Service shows
ClusterIPwith port80 → 8080. - The Ingress shows
my-service.<your-tenant>.kestrel.arbutus.cloudas 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.
If something goes wrong
Section titled “If something goes wrong”- Pod stuck in
Pending. Checkkubectl 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
CrashLoopBackOffwith Kyverno admission error. You modified thesecurityContextblock. 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-serviceandkubectl describe certificate -n <your-tenant>-prod my-service-tls. - ArgoCD shows
OutOfSyncbut 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.