Skip to content

Deploying on Amazon EKS

Docket is a single Go binary that needs one PostgreSQL database. This guide covers running it on Amazon EKS with PostgreSQL on RDS or Aurora, and S3 for attachments.

Docket ships no Helm chart and no manifest framework — the application is built to be “EKS-ready by default,” so deployment is one plain, hand-editable manifest. The container image is non-root and multi-arch (amd64 + arm64, so it runs on Graviton nodes); load-balancer-aware draining and Go memory tuning are handled inside the process. See the example manifest below.

  • An EKS cluster (1.30+) with the AWS Load Balancer Controller installed and an IAM OIDC provider associated (needed for IRSA).
  • RDS or Aurora PostgreSQL 15.4+ or 16.x reachable from the cluster.
  • An S3 bucket for attachments.
  • A container image: ghcr.io/bcnelson/docket — pin a released tag or digest.

Docket keeps everything in one database — tickets, events, the knowledge base, embeddings, job queue, and workflow state. It requires the vector (pgvector) and uuid-ossp extensions.

CREATE EXTENSION on RDS/Aurora requires the rds_superuser role. If Docket connects as a least-privilege application role, pre-create the extensions once as the master user so the startup migration is a no-op:

CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

For knowledge-base / embedding workloads, raise maintenance_work_mem in the RDS parameter group — HNSW index builds are memory-hungry.

Docket uses the AWS default credential chain when no static keys are configured, so it picks up an IRSA / Pod Identity role automatically — no long-lived access keys to store.

Create an IAM policy scoped to the attachments bucket:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::docket-attachments/*"
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::docket-attachments"
}
]
}

Create an IAM role that trusts the cluster’s OIDC provider, attach the policy, and annotate the Kubernetes ServiceAccount with the role ARN (shown in the manifest below). Leave storage.access_key_id unset — that is what selects the IRSA path.

Static keys still work (set storage.access_key_id / secret_access_key) for non-AWS or S3-compatible object stores, but on EKS prefer IRSA.

Sensitive values are supplied as environment variables from a Kubernetes Secret. At minimum that is the database URL; add integration secrets only if you enable those features.

Enable EKS Secrets envelope encryption with KMS on the cluster, and consider sourcing the Secret from AWS Secrets Manager via the External Secrets Operator rather than committing it.

Non-secret settings go in a docket.toml mounted from a ConfigMap at /etc/docket/docket.toml (a path Docket searches automatically). Secrets come from the Secret as environment variables, which take precedence.

Key EKS-relevant settings:

SettingWhy
server.allowed_hosts, server.allowed_originsSet to your real hostname — required behind an Ingress.
server.hsts_enabledEnable; TLS is terminated at the ALB.
database.max_connsSize against RDS max_connections — see Scaling.
storage.endpointMust be set to the regional S3 endpoint, e.g. https://s3.us-east-1.amazonaws.com. An empty endpoint disables attachments entirely.
storage.force_path_styleSet false for real AWS S3.
telemetry.otlp_endpointPoint at an in-cluster OTel/ADOT collector for traces.

One file: ServiceAccount, ConfigMap, Secret, Deployment, Service, and Ingress. Replace every CHANGEME. Apply with kubectl apply -f.

apiVersion: v1
kind: ServiceAccount
metadata:
name: docket
namespace: docket
annotations:
# IAM role created in step 2.
eks.amazonaws.com/role-arn: arn:aws:iam::CHANGEME:role/docket-s3
---
apiVersion: v1
kind: ConfigMap
metadata:
name: docket-config
namespace: docket
data:
docket.toml: |
[server]
allowed_hosts = ["docket.example.com"] # CHANGEME
allowed_origins = ["https://docket.example.com"] # CHANGEME
hsts_enabled = true
[database]
max_conns = 12
[storage]
endpoint = "https://s3.us-east-1.amazonaws.com" # CHANGEME region
region = "us-east-1" # CHANGEME
bucket = "docket-attachments" # CHANGEME
force_path_style = false
---
apiVersion: v1
kind: Secret
metadata:
name: docket-secrets
namespace: docket
type: Opaque
stringData:
# Prefer sourcing this from AWS Secrets Manager via External Secrets.
DOCKET_DATABASE__URL: "postgres://docket:CHANGEME@your-rds-endpoint:5432/docket?sslmode=require"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: docket
namespace: docket
spec:
replicas: 2
strategy:
rollingUpdate:
maxUnavailable: 0 # keep capacity during rollouts
maxSurge: 1
selector:
matchLabels: { app: docket }
template:
metadata:
labels: { app: docket }
spec:
serviceAccountName: docket
# Covers the predrain delay + graceful shutdown + DBOS stop.
terminationGracePeriodSeconds: 60
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
containers:
- name: docket
image: ghcr.io/bcnelson/docket:CHANGEME # pin a released tag/digest
ports:
- containerPort: 8080
envFrom:
- secretRef: { name: docket-secrets }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
resources:
requests: { cpu: "250m", memory: "512Mi" }
limits: { cpu: "1", memory: "1Gi" }
# Docket sizes GOMEMLIMIT from this memory limit automatically.
startupProbe: # tolerates a slow first boot / migrations
httpGet: { path: /readyz, port: 8080 }
periodSeconds: 5
failureThreshold: 30
readinessProbe:
httpGet: { path: /readyz, port: 8080 }
periodSeconds: 10
livenessProbe: # process-only — never /readyz
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
volumeMounts:
- { name: config, mountPath: /etc/docket, readOnly: true }
- { name: tmp, mountPath: /tmp }
volumes:
- name: config
configMap: { name: docket-config }
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: docket
namespace: docket
spec:
selector: { app: docket }
ports:
- { port: 80, targetPort: 8080 }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: docket
namespace: docket
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip # enables pod readiness gates
alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
alb.ingress.kubernetes.io/ssl-redirect: '443'
alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:CHANGEME
alb.ingress.kubernetes.io/healthcheck-path: /healthz
spec:
ingressClassName: alb
rules:
- host: docket.example.com # CHANGEME
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: docket
port: { number: 80 }

No preStop hook and no GOMEMLIMIT environment variable are needed — Docket handles load-balancer draining and Go memory tuning itself (see below).

Every pod applies pending migrations on startup. Concurrent pods are serialized by a PostgreSQL advisory lock, so this is safe under horizontal scale and during rollouts — no separate migration Job is required.

The one rule: keep migrations backward-compatible within a release (expand-only — add columns, don’t drop or rename in the same release that deploys code depending on the change). Combined with maxUnavailable: 0, this lets old and new pods coexist during a rolling update.

Docket instances are stateless — any pod serves any request, and sessions live in the database, so no sticky sessions are needed.

The real ceiling on replicas is the database connection budget. Each pod opens up to database.max_conns connections and permanently pins roughly 5 of them for LISTEN/NOTIFY channels and the serve advisory lock. Size so that:

replicas × database.max_conns + overhead < 0.8 × RDS max_connections

SHOW max_connections; on the instance gives the budget (RDS derives it from instance memory). Connect directly to the RDS endpoint — RDS Proxy pins a session whenever LISTEN is used, which negates its multiplexing.

A HorizontalPodAutoscaler is fine, but cap maxReplicas to stay within that budget. Add a PodDisruptionBudget (maxUnavailable: 1) so node drains and the Cluster Autoscaler don’t evict every replica at once.

On SIGTERM, Docket reports /readyz as 503 while still serving, waits server.shutdown_predrain_delay (default 5s) for the load balancer to deregister the pod, then drains in-flight requests. /healthz stays green throughout. This is why no preStop hook is needed; ensure terminationGracePeriodSeconds exceeds the predrain delay plus server.shutdown_timeout.

If the AI subsystem is enabled, run docket check-deploy as a pre-deploy gate — it flags running or paused AI agent runs pinned to a retired spec version that would be orphaned by the rollout. Keep agent specs backward-compatible within a release.

Logs are JSON on stdout — collect them with Fluent Bit to CloudWatch or your log stack. Set telemetry.otlp_endpoint to export traces to an in-cluster OTel/ADOT collector.

Prometheus metrics are served at /metrics on the application port. Scrape it in-cluster via the Service (a ServiceMonitor or pod annotations); it exposes only aggregate RED counters, no ticket data. If your Ingress is internet-facing and you want it hidden, add an Ingress path rule that does not route /metrics.

If the cluster restricts egress, allow outbound to:

  • PostgreSQL on RDS/Aurora (5432).
  • S3 (443) and any enabled external APIs — Anthropic/OpenAI, Slack, your OIDC issuer, SMTP/IMAP.

If you use a self-hosted model server (Ollama), point its endpoint at a Service DNS name — the localhost:11434 default cannot work across pods.