Skip to content

Latest commit

 

History

History
611 lines (522 loc) · 27.5 KB

File metadata and controls

611 lines (522 loc) · 27.5 KB

click — running and deploying the platform

How to run click: the local Docker stack in both modes, the end-to-end test suites, how to point it at a real S3 bucket, and the production blueprint for EKS and Terraform.

For the component map and the request path, start at ARCHITECTURE.md. For the design of the auth, ownership, and data-isolation layers, read DESIGN.md. To use the click CLI to deploy sites, read the README.

Before you finish a change, run go build ./... && go vet ./... && gofmt -l ./cmd ./internal && make test. Then run the smoke suite for each mode that the change affects (read Run the test suites).

Run it locally

Prerequisites

  • Go 1.26+ (go version)
  • Docker with Compose v2 (docker compose version). Docker Desktop on macOS works by default.
  • Free local ports: 8080 (nginx), 9000/9001 (MinIO), 5556 (dex, auth mode only)

Every AWS component has a local substitute: MinIO for S3 and STS, dex for the OIDC provider, and libsql-server for Turso. The Go code targets real AWS unchanged, because the endpoints come from environment variables.

1. Clone and start the stack (open mode)

git clone https://github.com/closeio/click.git && cd click
make up          # MinIO + PostgreSQL + clickd + nginx (docker compose)
make examples    # build the CLI into bin/ and deploy the three demo sites

Then open http://hello.click.localhost:8080/. Browsers resolve *.localhost to loopback natively. The apex http://click.localhost:8080/ lists every deployed site. For curl, pass the Host header, because only browsers resolve *.localhost:

curl -H "Host: hello.click.localhost" http://127.0.0.1:8080/

Open mode has no authentication. /api/identity reports a dev identity, and the CLI uses the MinIO credentials directly.

2. Use the CLI

The CLI talks to the S3 API of MinIO, and to the platform through nginx. It therefore needs this environment (the Makefile and the smoke scripts set the same values):

export AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin
export AWS_REGION=us-east-1 CLICK_S3_ENDPOINT=http://localhost:9000
export CLICK_API_URL=http://127.0.0.1:8080   # *.localhost doesn't resolve outside browsers

./bin/click deploy <dir> [--name <site>] [--keep-deleted]
./bin/click list
./bin/click delete <site> [--keep-data]
./bin/click version
./bin/click update

The full command and environment reference is in the README.

For a released binary, click version prints the release tag. For a dev build it prints the short commit SHA, plus -dirty when the tree is unclean. That SHA comes from the VCS information that Go embeds on its own, so make build needs no ldflags.

GoReleaser builds the releases (.goreleaser.yaml) for Linux amd64/arm64 and macOS arm64. A pushed tag like v0.1.0 runs the full CI suite. Once CI passes, the release job in .github/workflows/ci.yml publishes the binaries as a GitHub release. That job runs after the container image is pushed, so a tag never announces binaries whose clickd image is missing. To try the build locally and untagged, run goreleaser release --clean --snapshot. clickd is not part of the releases. It ships as a container image.

click update self-updates to the latest of those releases. It picks the archive for the current OS and architecture, verifies it against the checksums.txt of the release, and atomically swaps the running binary. A checksum mismatch aborts the install. If the repository is private, or if you reach the API rate limits, set GITHUB_TOKEN. CLICK_UPDATE_REPO and CLICK_UPDATE_API override the source, which helps with forks and testing.

click new generates a site from a natural-language prompt. Generation prefers the platform. When you are signed in (click login), the CLI calls POST /api/generate, and clickd runs the generation with the Anthropic key of the organization. There are then no per-laptop API keys, there are per-user rate limits, and there is one place to govern the model and the spend.

To enable this, the platform sets ANTHROPIC_API_KEY on clickd, plus CLICK_AI_MODEL to pick the default model. Without that key the endpoint answers 501, and the CLI falls back to calling the Claude API directly with local credentials (ANTHROPIC_API_KEY, or an ant auth login profile).

Both paths share the same contract (internal/gen). docs/site-api.md is embedded as context, so that a generated site really uses the platform APIs: identity, the per-site document DB, and realtime. A generated site is therefore not inert HTML. Structured outputs make the response a file list that always parses. And the CLI validates every path before it writes: no traversal, and no reserved prefixes.

Next to the site, the CLI writes an AGENTS.md, plus a CLAUDE.md that defers to it. They carry the prompt, the iteration rules, and the platform API guide. A coding agent pointed at the folder therefore keeps working by editing files and redeploying, instead of generating the site again. Expect a few minutes and roughly $0.10–0.50 per generation.

3. Optional: run with the identity layer (auth mode)

Auth mode adds dex, oauth2-proxy, and per-site libSQL databases. It enforces sign-in, ownership, and scoped deploy credentials. This is the full production behavior, locally. To switch modes, run make down first. It stops either stack:

make down
make up-auth
  • Browser: open http://hello.click.localhost:8080/. dex takes the redirect, and you sign in as dev@example.com / password. A second user, other@example.com / password, exists for ownership tests. One login covers every site subdomain.

  • CLI: log in once, then deploy. No AWS credentials are needed, because a deploy uses short-lived credentials scoped to the site, minted after an ownership check:

    export CLICK_API_URL=http://127.0.0.1:8080
    export CLICK_OIDC_ISSUER=http://dex.click.localhost:5556/dex
    # client id/secret default to the local dex values — no need to set them
    
    ./bin/click login                       # opens the browser → dex → done
    ./bin/click deploy examples/hello --name mysite   # first deploy claims the site
    ./bin/click access mysite               # owner + collaborators
    ./bin/click share mysite other@example.com
    ./bin/click logout

To read how this works, see Authentication and ownership.

Run the test suites

make test        # Go unit tests — hermetic, no Docker, no stack needed
make smoke       # 33 checks — needs the OPEN stack (make up)
make smoke-auth  # 34 checks — needs the AUTH stack (make up-auth)

make test fakes every external service in-process: S3, STS, libsql-server, the Claude API, the OIDC provider, and a Postgres wire-protocol server for the LISTEN/NOTIFY path. It therefore runs anywhere. make cover adds a coverage total.

Each smoke suite targets one mode. Between them, run make down and start the other stack. A suite that fails partway can leave state behind, for example a claimed site. Reset everything with:

docker compose -f docker-compose.yml -f docker-compose.auth.yml down -v

Troubleshooting

  • curl cannot reach hello.click.localhost. Only browsers resolve *.localhost. Use -H "Host: …" against 127.0.0.1:8080, as above.
  • click login fails with OIDC discovery … 404. Make sure that CLICK_OIDC_ISSUER is exactly http://dex.click.localhost:5556/dex.
  • click login fails with lookup dex.click.localhost: no such host. The CLI resolves the issuer through the system resolver, because a Go program has no --resolve flag like curl. macOS and systemd-resolved map *.localhost to loopback, but a bare container does not. Add it: echo '127.0.0.1 dex.click.localhost' >> /etc/hosts. make smoke-auth makes sure of this before it starts the OAuth flow.
  • Ports already in use. Stop whatever holds 8080, 9000, 9001, or 5556. Or edit the port mappings in docker-compose.yml.
  • Strange auth-mode failures after experiments. Stale volumes are usually the cause, such as dex keys or claimed sites. Run the down -v reset above.

Running against real AWS S3

  1. Create a bucket and point everything at it. Unset CLICK_S3_ENDPOINT, set CLICK_BUCKET, and use your normal AWS credential chain for the CLI and for clickd.

  2. Give nginx read access to the objects. The options, in order of preference:

    • Run nginx in a VPC with an S3 gateway endpoint. Add a bucket policy that allows s3:GetObject on sites/* only through that endpoint, with the aws:sourceVpce condition. This needs no public access and no signing.
    • Mount the bucket with mountpoint-s3 and serve it as plain files (root /mnt/click;). This is the closest analogue to the gcsfuse design of the original.
    • If neither is possible, sign the requests in nginx with njs (SigV4).

    Then edit nginx/click.conf. Point the click_s3 upstream at <bucket>.s3.<region>.amazonaws.com:443. Set proxy_set_header Host to that hostname. Drop the bucket from the proxy_pass path, which is virtual-hosted style. And set proxy_ssl_server_name on.

  3. Set up DNS and TLS: a wildcard record *.click.<your-domain> that points at nginx, and a wildcard certificate.

Deploying to production on EKS with Terraform

Two views of the same deployment. This section is the Terraform-centric blueprint: the S3 bucket, the VPC endpoint, and the IAM/IRSA roles as HCL. Its Kubernetes-centric companion is docs/deploy-eks-github.md, which holds complete manifests for every workload. That companion uses GitHub as the identity provider through dex, and it runs Postgres and libSQL in-cluster. That guide is checked in as applyable YAML under kubernetes/. Apply the Terraform layer from here. Deploy the workloads from there.

Everything that docker-compose fakes locally has a managed AWS equivalent. This is a reference blueprint for EKS. Adapt the names, the tagging, and the module or chart structure to your own conventions. Every environment-specific value is a variable. The blueprint splits into two layers:

  • Terraform provisions the shared infrastructure: S3, IAM (via IRSA), RDS, Secrets Manager, and ACM. This layer is the same however you run the workload.
  • Kubernetes runs the three containers (nginx, oauth2-proxy, clickd) as one Pod. An ALB fronts the Pod, and the AWS Load Balancer Controller provisions that ALB from an Ingress.

The blueprint assumes an existing EKS cluster with IRSA enabled (an IAM OIDC provider), and with the AWS Load Balancer Controller and ExternalDNS installed. It also assumes a VPC with private subnets, a Route 53 hosted zone, and the clickd and nginx images pushed to ECR. And it assumes an OIDC application registered with your identity provider (Google Workspace, Okta, Entra, and others).

Request flow in AWS

employee ─► Route53 *.click.example.com ─► ALB (TLS via ACM, from the Ingress)
                                             │
                                             ▼   one Pod, containers share localhost
                                     ┌───────────────────────────────────────────────┐
                                     │ nginx :80 ──auth_request──► oauth2-proxy :4180 │──► your IdP (OIDC)
                                     │   │  /*     ──► S3 (VPC gateway endpoint)       │
                                     │   │  /api/* ──► clickd :8080 ──► RDS PostgreSQL │
                                     │   └─────────────────────────► clickd ─STS─► AssumeRole(click-deployer)
                                     └───────────────────────────────────────────────┘
                                       clickd's pod identity comes from IRSA (its service account → the click-clickd role)
click deploy ─► POST /api/deploy-token (bearer) ─► clickd checks ownership ─► scoped S3 creds ─► s3://…/sites/<name>/

Keep this property in view: the own role of clickd cannot write site objects. It can only list the bucket and assume the deploy role. The deploy role is what gives out per-site, prefix-scoped, 15-minute credentials. No standing credential in the system can write more than one site at a time.

Variables

variable "region"           { type = string }
variable "vpc_id"           { type = string }
variable "eks_cluster_name" { type = string }
variable "route53_zone_id"  { type = string }
variable "domain"           { default = "click.example.com" } # sites are <name>.${domain}
variable "oidc_issuer_url"  { type = string } # your IdP, e.g. https://accounts.google.com
variable "oidc_client_id"   { type = string }

The Kubernetes manifests further down use literal placeholders (<region>, <account-id>, the domain, the IdP issuer and client), so that they read standalone. In practice you template them with Helm or Kustomize, from these same values and from the Terraform outputs (role ARNs, bucket name, ACM ARN).

S3 bucket (private) + read path for nginx

resource "aws_s3_bucket" "sites" {
  bucket = "click-sites-${var.domain}"
}

resource "aws_s3_bucket_public_access_block" "sites" {
  bucket                  = aws_s3_bucket.sites.id
  block_public_acls       = true
  ignore_public_acls      = true
  block_public_policy     = true
  restrict_public_buckets = true
}

# nginx reads objects through an S3 gateway endpoint with unsigned requests;
# the bucket policy allows GetObject on sites/* only when the request arrives
# via this endpoint. The aws:sourceVpce condition keeps the statement
# non-public, so Block Public Access above can stay fully on. (If your account
# still rejects it, serve reads with signed requests instead — see
# "Running against real AWS S3".)
data "aws_route_tables" "vpc" { vpc_id = var.vpc_id }

resource "aws_vpc_endpoint" "s3" {
  vpc_id            = var.vpc_id
  service_name      = "com.amazonaws.${var.region}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = data.aws_route_tables.vpc.ids
}

resource "aws_s3_bucket_policy" "sites" {
  bucket = aws_s3_bucket.sites.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "ReadOnlyViaVpcEndpoint"
      Effect    = "Allow"
      Principal = "*"
      Action    = "s3:GetObject"
      Resource  = "${aws_s3_bucket.sites.arn}/sites/*"
      Condition = { StringEquals = { "aws:sourceVpce" = aws_vpc_endpoint.s3.id } }
    }]
  })
}

IAM — the deploy role is the whole point (via IRSA)

The pod of clickd gets its AWS identity from IRSA. Its Kubernetes service account carries an annotation for the click-clickd role. The trust policy of that role trusts the IAM OIDC provider of the cluster, for exactly that service account. The AWS SDK inside clickd reads this automatically, through the projected token and the injected AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE. There is no code change and there are no keys.

# The cluster's IAM OIDC provider (created when you enabled IRSA on the cluster).
data "aws_caller_identity" "current" {}
data "aws_eks_cluster" "this" { name = var.eks_cluster_name }

locals {
  oidc_host = replace(data.aws_eks_cluster.this.identity[0].oidc[0].issuer, "https://", "")
  oidc_arn  = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/${local.oidc_host}"
}

# clickd's role: assume the deploy role, and list the bucket (for the homepage /
# GET /api/sites and the health check). It deliberately CANNOT write objects.
# Trust is scoped to the clickd service account in the click namespace.
resource "aws_iam_role" "clickd" {
  name = "click-clickd"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Federated = local.oidc_arn }
      Action    = "sts:AssumeRoleWithWebIdentity"
      Condition = { StringEquals = {
        "${local.oidc_host}:sub" = "system:serviceaccount:click:clickd"
        "${local.oidc_host}:aud" = "sts.amazonaws.com"
      } }
    }]
  })
}

resource "aws_iam_role_policy" "clickd" {
  role = aws_iam_role.clickd.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      { Effect = "Allow", Action = "sts:AssumeRole", Resource = aws_iam_role.deployer.arn },
      { Effect = "Allow", Action = "s3:ListBucket",  Resource = aws_s3_bucket.sites.arn },
    ]
  })
}

# The deploy role clickd assumes once per deploy. Its policy allows all of
# sites/*; the inline SESSION policy clickd passes on AssumeRole (built in
# internal/creds) narrows each assumption to a single sites/<site>/*.
resource "aws_iam_role" "deployer" {
  name = "click-deployer"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { AWS = aws_iam_role.clickd.arn } # only clickd may assume it
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "deployer" {
  role = aws_iam_role.deployer.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      { Effect = "Allow", Action = ["s3:PutObject", "s3:DeleteObject", "s3:GetObject"],
        Resource = "${aws_s3_bucket.sites.arn}/sites/*" },
      { Effect = "Allow", Action = "s3:ListBucket", Resource = aws_s3_bucket.sites.arn },
    ]
  })
}

Set CLICK_DEPLOY_ROLE_ARN = aws_iam_role.deployer.arn. clickd assumes that role with its IRSA identity, so CLICK_DEPLOY_KEY, SECRET, and CLICK_STS_ENDPOINT stay unset. Those three are only for local MinIO.

RDS, secrets

resource "aws_db_instance" "clickd" {
  identifier             = "click"
  engine                 = "postgres"
  engine_version         = "17"
  instance_class         = "db.t4g.small"
  allocated_storage      = 20
  db_name                = "click"
  username               = "click"
  manage_master_user_password = true          # RDS stores it in Secrets Manager
  db_subnet_group_name   = aws_db_subnet_group.clickd.name
  vpc_security_group_ids = [aws_security_group.rds.id]
  skip_final_snapshot    = false
}

# Synced into a Kubernetes Secret (see below) and consumed via secretKeyRef.
resource "aws_secretsmanager_secret" "database_url"  { name = "click/database-url" }
resource "aws_secretsmanager_secret" "oidc_secret"   { name = "click/oidc-client-secret" }
resource "aws_secretsmanager_secret" "cookie_secret" { name = "click/oauth2-cookie-secret" }
# Populate database-url as postgres://click:<pw>@${aws_db_instance.clickd.address}:5432/click,
# oidc-client-secret from your IdP, cookie-secret from `openssl rand -base64 32`.

The RDS security group needs ingress on 5432 from the cluster nodes. If you use security groups for pods, it needs ingress from the Pod security group instead. The subnet group and the security group are standard boilerplate, and they are omitted here.

ACM certificate (Terraform)

The wildcard certificate is the last Terraform piece. The Ingress references it by ARN.

resource "aws_acm_certificate" "click" {
  domain_name               = var.domain
  subject_alternative_names = ["*.${var.domain}"] # wildcard for every site subdomain
  validation_method         = "DNS"
  # Add the usual aws_route53_record validation entries + an
  # aws_acm_certificate_validation so it's issued before the Ingress uses it.
}

Kubernetes runtime — one Pod, nginx + oauth2-proxy + clickd

The three containers run in a single Pod, so they reach each other over localhost, exactly like the compose network. The service account carries the IRSA annotation. The AWS Load Balancer Controller and ExternalDNS produce the ALB and the Route 53 records from the Ingress.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: clickd
  namespace: click
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::<account-id>:role/click-clickd # aws_iam_role.clickd
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: click, namespace: click }
spec:
  replicas: 2
  selector: { matchLabels: { app: click } }
  template:
    metadata: { labels: { app: click } }
    spec:
      serviceAccountName: clickd            # IRSA identity for clickd's AWS calls
      containers:
        - name: nginx                       # your image: prod click.conf + auth-on.conf baked in
          image: <account>.dkr.ecr.<region>.amazonaws.com/click/nginx:<tag>
          ports: [{ containerPort: 80 }]
          readinessProbe: { httpGet: { path: /healthz, port: 80 } }
        - name: oauth2-proxy
          image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.3
          env:
            - { name: OAUTH2_PROXY_HTTP_ADDRESS,        value: "0.0.0.0:4180" }
            - { name: OAUTH2_PROXY_REVERSE_PROXY,       value: "true" }
            - { name: OAUTH2_PROXY_PROVIDER,            value: "oidc" }
            - { name: OAUTH2_PROXY_OIDC_ISSUER_URL,     value: "https://accounts.google.com" } # your IdP
            - { name: OAUTH2_PROXY_CLIENT_ID,           value: "<oidc-client-id>" }
            - { name: OAUTH2_PROXY_EMAIL_DOMAINS,       value: "example.com" }   # your company domain
            - { name: OAUTH2_PROXY_COOKIE_DOMAINS,      value: ".click.example.com" }
            - { name: OAUTH2_PROXY_COOKIE_SECURE,       value: "true" }
            - { name: OAUTH2_PROXY_WHITELIST_DOMAINS,   value: ".click.example.com" }
            - { name: OAUTH2_PROXY_REDIRECT_URL,        value: "https://click.example.com/oauth2/callback" }
            - { name: OAUTH2_PROXY_SET_XAUTHREQUEST,    value: "true" }
            - { name: OAUTH2_PROXY_SKIP_PROVIDER_BUTTON, value: "true" }
            - { name: OAUTH2_PROXY_UPSTREAMS,           value: "static://202" }
            # Accept the click CLI's bearer token (audience = the CLI's public client):
            - { name: OAUTH2_PROXY_SKIP_JWT_BEARER_TOKENS, value: "true" }
            - { name: OAUTH2_PROXY_EXTRA_JWT_ISSUERS,   value: "https://accounts.google.com=click-cli" }
            - name: OAUTH2_PROXY_CLIENT_SECRET
              valueFrom: { secretKeyRef: { name: click, key: oidc-client-secret } }
            - name: OAUTH2_PROXY_COOKIE_SECRET
              valueFrom: { secretKeyRef: { name: click, key: cookie-secret } }
        - name: clickd
          image: <account>.dkr.ecr.<region>.amazonaws.com/click/clickd:<tag>
          env:
            - { name: CLICK_BUCKET,           value: "click-sites-click.example.com" }
            - { name: CLICK_DOMAIN,           value: "click.example.com" }
            - { name: CLICK_DEPLOY_ROLE_ARN,  value: "arn:aws:iam::<account-id>:role/click-deployer" }
            - { name: AWS_REGION,             value: "<region>" }
            - name: CLICK_DATABASE_URL
              valueFrom: { secretKeyRef: { name: click, key: database-url } }
---
apiVersion: v1
kind: Service
metadata: { name: click, namespace: click }
spec:
  selector: { app: click }
  ports: [{ port: 80, targetPort: 80 }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: click
  namespace: click
  annotations:
    alb.ingress.kubernetes.io/scheme: internal          # internal tool: reach it over VPN / corp net
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
    alb.ingress.kubernetes.io/certificate-arn: <aws_acm_certificate.click.arn>
    alb.ingress.kubernetes.io/healthcheck-path: /healthz # bypasses auth (see nginx apex block)
    external-dns.alpha.kubernetes.io/hostname: "click.example.com,*.click.example.com"
spec:
  ingressClassName: alb
  rules:
    - host: "click.example.com"
      http: { paths: [{ path: /, pathType: Prefix, backend: { service: { name: click, port: { number: 80 } } } }] }
    - host: "*.click.example.com"
      http: { paths: [{ path: /, pathType: Prefix, backend: { service: { name: click, port: { number: 80 } } } }] }

The External Secrets Operator or the Secrets Store CSI driver syncs the click Kubernetes Secret (keys database-url, oidc-client-secret, cookie-secret) from the three Secrets Manager secrets above. Nothing sensitive therefore lives in the manifests or in Git.

Two production deltas in the nginx image

The local nginx/click.conf targets the compose network. The image that you push to ECR needs two edits baked in, and it also bakes or mounts auth-on.conf as click-auth.conf:

  1. Upstreams are localhost. The three containers share the network namespace of the Pod. clickd:8080 and oauth2-proxy:4180 therefore become 127.0.0.1:8080 and 127.0.0.1:4180, and the Docker resolver 127.0.0.11 line goes away.
  2. The static upstream is the real bucket. Point the location / proxy at <bucket>.s3.<region>.amazonaws.com:443. Set proxy_ssl_server_name on and set the Host header to that name, exactly as Running against real AWS S3 describes.

Deploy workflow

# 1. build and push the images
docker build -t $CLICKD_IMAGE .            && docker push $CLICKD_IMAGE
docker build -t $NGINX_IMAGE  ./nginx/prod && docker push $NGINX_IMAGE   # your prod nginx image

# 2. infrastructure, then the workload
terraform apply                                   # S3, IAM/IRSA, RDS, secrets, ACM
# kubernetes/ holds the manifests (namespace, SA, Deployment, Service, Ingress);
# apply them in the order kubernetes/README.md gives, and make the two swaps it
# describes for this profile: RDS instead of the in-cluster Postgres, and your
# own OIDC provider instead of dex.

# 3. users authenticate and deploy — no AWS credentials on their laptops
export CLICK_DOMAIN=click.example.com CLICK_BUCKET=click-sites-click.example.com
export AWS_REGION=us-east-1
export CLICK_OIDC_ISSUER=https://accounts.google.com        # your IdP
click login
click deploy ./site --name my-app                           # claims it, writes via a scoped token

The clickd build in step 1 is optional. CI publishes a multi-arch image (amd64 and arm64) for every commit on main and every v* tag, so CLICKD_IMAGE=ghcr.io/closeio/clickd:<tag> works as it is. The nginx image is always yours to build, because it bakes in your production click.conf.

To make CLI logins work, register a second public OAuth client (click-cli) with your IdP. It must permit loopback redirects (http://localhost/), and it must match CLICK_OIDC_CLIENT_ID. That is the client whose audience oauth2-proxy trusts through OAUTH2_PROXY_EXTRA_JWT_ISSUERS above.

Notes worth knowing

  • ALB authenticate-oidc can do the login at the load balancer instead of oauth2-proxy. But it sets x-amzn-oidc-* headers, not the X-Auth-Request-Email that clickd reads. More important, it 302s the bearer-token requests of the CLI into a browser flow. oauth2-proxy is what makes the click login → scoped-deploy path work, so this repo keeps it.
  • Security groups for Pods (or a shared node security group) must let the Pod reach RDS on 5432 and the S3 gateway endpoint. The ALB that the controller creates handles ingress from your network on 443.
  • Not on EKS? This is the only layer that changes. On ECS Fargate the same three containers run in one task, and they also share localhost. The IRSA role becomes the task role. The Ingress becomes an ALB, a target group, and an aws_route53_record. S3, the deploy role, RDS, and the secrets are identical.