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). - You have requested a DNS record for your service hostname from RCS — records for tenant hostnames are currently created manually via ticket. You can carry on without it; the URL just will not resolve until the record exists.
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, but the certificate stays READY: False until the DNS record for the hostname exists (see the caution above).
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 the hostname does not resolve, the DNS record probably has not been created yet — see the caution in Service and Ingress and the troubleshooting entry below.
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, usually cluster capacity. (The recommended variant’s anti-affinity is a softpreferredrule and never blocks scheduling — it can only causePendingif you changed it torequiredDuringSchedulingIgnoredDuringExecution.) If instead no new pod appears at all, check the ReplicaSet’s events (kubectl describe rs -n <your-tenant>-prod) forforbidden: exceeded quota— the pod was rejected at creation and never reachesPending. Free capacity or request more via RCS ticket. - No pods appear and the ReplicaSet shows a Kyverno admission error. Kyverno rejects non-compliant pods at admission, so the pod is never created — the denial shows up in the ReplicaSet’s events (
kubectl describe rs -n <your-tenant>-prod). 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. - Hostname does not resolve (NXDOMAIN), or certificate stuck at
READY: Falsefor more than a few minutes. The DNS record formy-service.<your-tenant>.kestrel.arbutus.cloudprobably does not exist yet — DNS records are currently created manually (see the caution in Service and Ingress). Check withkubectl get certificate -n <your-tenant>-prod, and open a ticket with RCS to have the hostname created. Once the record exists, cert-manager retries and issues the certificate without any action on your side. - 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.