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.
Prerequisites
Section titled “Prerequisites”- 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.
1. Prepare the database
Section titled “1. Prepare the database”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.
2. Grant S3 access with IRSA
Section titled “2. Grant S3 access with IRSA”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.
3. Create the secret
Section titled “3. Create the secret”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.
4. Configuration
Section titled “4. Configuration”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:
| Setting | Why |
|---|---|
server.allowed_hosts, server.allowed_origins | Set to your real hostname — required behind an Ingress. |
server.hsts_enabled | Enable; TLS is terminated at the ALB. |
database.max_conns | Size against RDS max_connections — see Scaling. |
storage.endpoint | Must 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_style | Set false for real AWS S3. |
telemetry.otlp_endpoint | Point at an in-cluster OTel/ADOT collector for traces. |
Example manifest
Section titled “Example manifest”One file: ServiceAccount, ConfigMap, Secret, Deployment, Service, and
Ingress. Replace every CHANGEME. Apply with kubectl apply -f.
apiVersion: v1kind: ServiceAccountmetadata: name: docket namespace: docket annotations: # IAM role created in step 2. eks.amazonaws.com/role-arn: arn:aws:iam::CHANGEME:role/docket-s3---apiVersion: v1kind: ConfigMapmetadata: name: docket-config namespace: docketdata: 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: v1kind: Secretmetadata: name: docket-secrets namespace: dockettype: OpaquestringData: # 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/v1kind: Deploymentmetadata: name: docket namespace: docketspec: 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: v1kind: Servicemetadata: name: docket namespace: docketspec: selector: { app: docket } ports: - { port: 80, targetPort: 8080 }---apiVersion: networking.k8s.io/v1kind: Ingressmetadata: 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: /healthzspec: 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).
Migrations
Section titled “Migrations”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.
Scaling and connections
Section titled “Scaling and connections”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_connectionsSHOW 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.
Graceful shutdown and rollouts
Section titled “Graceful shutdown and rollouts”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.
Observability
Section titled “Observability”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.
Network egress
Section titled “Network egress”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.