Stage 7 · Master
Shipping It — Docker & Kubernetes
Kubernetes Deployments, Services & Ingress
Fieldwork mapped each Go service to one Deployment, hid internal traffic behind Services, and exposed only the gateway through Ingress.
One Deployment per Running Service
Fieldwork's deployable units were already clear at the repo level: api-gateway, auth-service, and tasks-service each had their own cmd/ entrypoint, their own health endpoints, and their own operational concerns. We kept that boundary in Kubernetes. One Deployment owned one service binary. We did not bundle multiple processes into a single pod just because they collaborated in the same request path. That would have turned scaling and failure domains into guesswork. api-gateway needs internet-facing traffic handling and often scales differently from tasks-service, which spends more time on database-backed task queries. Kubernetes should preserve that difference, not hide it inside one oversized pod.
We also rejected the temptation to model everything as one giant Deployment with several containers. Sidecars are useful when containers are inseparable parts of the same workload, but auth-service is not a sidecar of api-gateway. It is a separately deployable unit with its own version history and rollback needs. Splitting by service kept ownership and rollout blast radius obvious.
| Model | Why teams choose it | Why Fieldwork did or did not |
|---|---|---|
| One Deployment per service | Clear scaling and rollout boundaries | Chosen. Matches repo ownership and operational behavior. |
| One pod with multiple business containers | Fewer Kubernetes objects on paper | Rejected. Couples release cadence and resource tuning across unrelated services. |
| Expose every service directly | Feels simple at first | Rejected. Public surface area explodes and auth/rate-limit policy becomes fragmented. |
Probes Had to Reflect Real Readiness
The most expensive Kubernetes mistake is treating "process started" as the same thing as "safe to receive traffic." Fieldwork's services all exposed /health/live and /health/ready, but those endpoints did different jobs. Liveness only answered whether the process was healthy enough not to be killed. Readiness answered whether the service could fulfill its contract right now: config loaded, HTTP server accepting requests, and critical dependencies such as Postgres reachable enough for the service's startup guarantees. We kept the readiness rule intentionally narrow. tasks-service did not block forever waiting for a nonessential downstream analytics sink, but it absolutely did block if the tenant-scoped Postgres connection pool could not initialize.
apiVersion: apps/v1
kind: Deployment
metadata:
name: tasks-service
namespace: fieldwork
spec:
replicas: 3
selector:
matchLabels:
app: tasks-service
template:
metadata:
labels:
app: tasks-service
spec:
containers:
- name: tasks-service
image: ghcr.io/thesyscoder/fieldwork/tasks-service:sha-9f2a6c1
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health/live
port: 8080
periodSeconds: 10
failureThreshold: 3The pod is not considered ready until /health/ready passes. That keeps a newly started or degraded tasks-service instance out of the Service endpoints list even though the process is technically running.
If liveness fails whenever Postgres has a brief hiccup, Kubernetes turns a transient dependency issue into a restart storm. Readiness should remove a pod from traffic; liveness should be reserved for cases where restarting the process is the right fix.
Services Defined the Network Shape
Services gave Fieldwork its stable in-cluster network identity. Each Deployment got a ClusterIP Service with a stable DNS name, and the gateway talked to auth-service.fieldwork.svc.cluster.local and tasks-service.fieldwork.svc.cluster.local rather than to pod IPs. That sounds basic because it is, but it is the reason pods can roll, die, and reschedule without forcing configuration rewrites anywhere else. More importantly, it let us keep internal APIs internal. tasks-service had a Service, but it did not have a public hostname or external LoadBalancer because clients were never supposed to call it directly.
apiVersion: v1
kind: Service
metadata:
name: tasks-service
namespace: fieldwork
spec:
selector:
app: tasks-service
ports:
- name: http
port: 80
targetPort: 8080
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: api-gateway
namespace: fieldwork
spec:
selector:
app: api-gateway
ports:
- name: http
port: 80
targetPort: 8080
type: ClusterIPBoth Services are ClusterIP because Ingress, not a Service type change, is what exposes api-gateway publicly. Internal services keep private DNS identities without widening the external attack surface.
We deliberately did not give every service its own Ingress and public DNS name. That would have bypassed gateway-level auth, versioning, and rate limiting that earlier modules already established. Once clients can hit tasks-service directly, the gateway stops being the contract boundary and starts being optional ceremony. Services were the right abstraction for internal traffic because they preserved separation without multiplying public entrypoints.
Ingress Exposed Only the Public Edge
Ingress existed for one reason in Fieldwork: expose api-gateway as the public edge and route versioned API traffic through it. That let TLS termination, host routing, and external rate-limit policy stay centralized. We chose a single host such as api.fieldwork.dev with path routing to /v1 instead of inventing service-specific public hostnames. The API already had a logical front door. Kubernetes should reflect that application architecture, not undo it.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: fieldwork-api
namespace: fieldwork
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
spec:
ingressClassName: nginx
tls:
- hosts:
- api.fieldwork.dev
secretName: fieldwork-api-tls
rules:
- host: api.fieldwork.dev
http:
paths:
- path: /v1
pathType: Prefix
backend:
service:
name: api-gateway
port:
number: 80The external contract terminates at api-gateway. Internal services remain routable only inside the cluster, which keeps authentication, tenant checks, and contract versioning in one place.
Creating one public hostname per team or per service may look tidy organizationally, but it often erodes architecture. Fieldwork used one public Ingress because clients had one supported entrypoint: the gateway.
What We Actually Shipped
Fieldwork mapped each deployable Go service to its own Deployment, gave each of them a stable ClusterIP Service for in-cluster discovery, and exposed only api-gateway through Ingress. Readiness and liveness probes were wired to separate endpoints with different intent, which kept bad pods out of rotation without causing unnecessary restart storms. That structure matched the service boundaries already established in the repository instead of forcing Kubernetes-specific coupling back into the codebase.
The key rejected alternative was not Kubernetes itself but the urge to collapse boundaries for convenience. One pod running too much, too many public hostnames, or probes that confuse availability with recoverability all make the platform noisier than it needs to be. The final deployment shape was intentionally boring: one service, one Deployment, one internal Service, and one external gateway. Boring is exactly what you want in production routing.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.