> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getnao.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy on AWS

> Run nao on AWS with EC2, ECS on Fargate, or Kubernetes (EKS), backed by an RDS PostgreSQL database

This page walks through three ways of running the `getnao/nao` container on AWS. Pick the one that matches how your team already runs things:

|                 | EC2 + Docker                                               | ECS on Fargate                                                                       | Kubernetes / EKS                                            |
| --------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| Best for        | A first deployment, small teams, full control over the box | Teams that want managed containers without servers (the AWS equivalent of Cloud Run) | Teams that already operate a cluster                        |
| What you manage | The VM, Docker, OS updates                                 | Task definition and service                                                          | Manifests, ingress, node groups                             |
| Scaling         | Vertical (bigger instance)                                 | Task count, autoscaling                                                              | Replicas, HPA                                               |
| HTTPS           | Reverse proxy on the box, or an ALB                        | ALB + ACM certificate                                                                | Ingress + ACM certificate                                   |
| Secrets         | `.env` file on disk, or SSM Parameter Store                | Secrets Manager, injected into the task                                              | Kubernetes `Secret`, optionally synced from Secrets Manager |

Whatever option you choose, three things are the same:

1. **An RDS PostgreSQL database** stores users, chats, and settings.
2. **`BETTER_AUTH_SECRET` and `BETTER_AUTH_URL`** must be set correctly or sign-in will not work.
3. The container listens on port **5005**.

They are covered first, then each option in turn. This page assumes you have already gone through [Step 1 and 2 of the Deployment Guide](/nao-agent/self-hosting/deployment-guide): a nao context repository with secrets replaced by environment variables, and either a Dockerfile that bakes it into the image or a Git URL the container can clone at startup.

## Before you start: the RDS database

nao needs a PostgreSQL database for everything that is not context: user accounts, sessions, chats, automations, and settings.

### Create the instance

1. In the RDS console, **Create database** -> **PostgreSQL**. Any supported major version works; pick the latest.
2. Choose the **Free tier** or **Dev/Test** template for a small team, **Production** if you want Multi-AZ. A `db.t4g.micro` or `db.t4g.small` is plenty to start.
3. Set a master username and password. Store the password - you will need it for `DB_URI`.
4. Under **Connectivity**, place the database in the **same VPC** as the compute you are about to create, and set **Public access** to **No**.
5. Create a dedicated security group for the database (for example `nao-rds-sg`). You will add an inbound rule to it in each option below, allowing port `5432` from the compute's security group.
6. Under **Additional configuration**, set an **Initial database name**, for example `nao`.

Once the instance is available, copy its **endpoint** from the **Connectivity & security** tab. Your connection string is:

```text theme={null}
postgres://<master-username>:<password>@<rds-endpoint>:5432/nao
```

### Enable SSL

Recent RDS PostgreSQL versions ship with `rds.force_ssl=1` in the default parameter group, so unencrypted connections are refused. Set `DB_SSL=true` on the container so nao connects over TLS:

```bash theme={null}
DB_URI=postgres://nao_admin:...@nao-db.abc123.eu-west-1.rds.amazonaws.com:5432/nao
DB_SSL=true
```

nao runs its migrations automatically on startup, so an empty database is all you need.

<Info>
  The RDS instance must be reachable from the container on port 5432. On AWS this is a security-group question, not a credentials question: if nao logs `connection timed out` at startup, the inbound rule on the RDS security group is missing. Each option below tells you which security group to allow.
</Info>

## Before you start: the environment variables

These variables are required on every option. Everything sensitive should be a secret (Secrets Manager, SSM Parameter Store, or a Kubernetes `Secret`), never a plain-text value in a task definition or manifest committed to Git.

| Variable                                     | Secret? | Description                                                                                                                                                       |
| -------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DB_URI`                                     | yes     | The RDS connection string from above                                                                                                                              |
| `DB_SSL`                                     | no      | `true`, because RDS enforces TLS                                                                                                                                  |
| `BETTER_AUTH_SECRET`                         | yes     | Signs sessions. See below                                                                                                                                         |
| `BETTER_AUTH_URL`                            | no      | Public URL of the deployment. See below                                                                                                                           |
| `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`       | yes     | At least one LLM provider key (you can also define it in the Settings UI)                                                                                         |
| `NAO_CONTEXT_SOURCE` + `NAO_CONTEXT_GIT_URL` | partly  | Set `NAO_CONTEXT_SOURCE=git` and the repo URL to clone the context at startup instead. Add `NAO_CONTEXT_GIT_SSH_KEY` or `NAO_CONTEXT_GIT_TOKEN` for private repos |
| Warehouse credentials                        | yes     | Whatever your `nao_config.yaml` references with `env(...)`, for example `SNOWFLAKE_PASSWORD` or `GCP_SERVICE_ACCOUNT_KEY_JSON`                                    |

### `BETTER_AUTH_SECRET`

A long random string used to sign authentication sessions. Generate it **once**:

```bash theme={null}
openssl rand -base64 32
```

<Warning>
  **This is critical.** If `BETTER_AUTH_SECRET` is not set, the container generates a new one every time it starts. On ECS or Kubernetes that means every deploy, every scale event, and every task replacement signs out all your users. Generate the value once, store it as a secret, and reuse it across every revision of the deployment.
</Warning>

### `BETTER_AUTH_URL`

The URL your users type in their browser, scheme included and without a trailing path. It is used to build sign-in callbacks, invitation and password-reset links, and to validate the origin of requests, so it must match **exactly** what is in front of the container:

```bash theme={null}
BETTER_AUTH_URL=https://nao.your-company.com
```

* If you put an ALB or reverse proxy with TLS in front, this is `https://` even though the container itself speaks plain HTTP on 5005.
* If you do not have a domain yet, use the load balancer's DNS name (`http://nao-alb-123456.eu-west-1.elb.amazonaws.com`) or the instance's public IP, and update the variable when you map a domain. A wrong value here typically shows up as a redirect loop or an "invalid origin" error after sign-in.
* Google, GitHub, or SAML sign-in must use the same URL as their redirect URI base. See [Admin setup](/nao-agent/chat/admin/setup).

## Option 1: EC2 with Docker

The most direct option: one virtual machine, Docker installed by hand, the container started with a compose file. Good for a first deployment and for teams that want to be able to `ssh` in and look.

### 1.1 Launch the instance

1. In EC2, **Launch instance** with **Ubuntu Server 24.04 LTS**.
2. Instance type: `t3.medium` (2 vCPU, 4 GB) is the minimum that runs comfortably; `t3.large` if you expect more than a handful of concurrent users.
3. Storage: 30 GB gp3.
4. Network: the **same VPC** as the RDS instance. Create a security group (for example `nao-ec2-sg`) that allows inbound `22` from your IP, and `80` and `443` from anywhere.
5. Attach a key pair, launch, and note the public IP. Allocate an **Elastic IP** and associate it so the address survives a stop/start.

### 1.2 Open the database to the instance

On the RDS security group `nao-rds-sg`, add an inbound rule:

| Type       | Port | Source       |
| ---------- | ---- | ------------ |
| PostgreSQL | 5432 | `nao-ec2-sg` |

Referencing the instance's security group rather than an IP means the rule keeps working if you replace the instance.

### 1.3 Install Docker

```bash theme={null}
ssh -i your-key.pem ubuntu@<elastic-ip>

sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

sudo usermod -aG docker ubuntu
newgrp docker
docker run --rm hello-world
```

### 1.4 Configure and start nao

Create a working directory and an `.env` file. This file holds your secrets, so keep it readable by your user only.

```bash theme={null}
mkdir -p ~/nao && cd ~/nao
touch .env && chmod 600 .env
```

`.env`:

```bash theme={null}
DB_URI=postgres://nao_admin:...@nao-db.abc123.eu-west-1.rds.amazonaws.com:5432/nao
DB_SSL=true

BETTER_AUTH_SECRET=<output of openssl rand -base64 32>
BETTER_AUTH_URL=https://nao.your-company.com

OPENAI_API_KEY=sk-...

NAO_CONTEXT_SOURCE=git
NAO_CONTEXT_GIT_URL=git@github.com:your-org/your-nao-context.git
NAO_CONTEXT_GIT_SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----"

SNOWFLAKE_PASSWORD=...
```

`docker-compose.yml`:

```yaml theme={null}
services:
  nao:
    image: getnao/nao:latest
    restart: unless-stopped
    env_file: .env
    ports:
      - "127.0.0.1:5005:5005"
    volumes:
      - nao-storage:/app/storage
    environment:
      NAO_STORAGE_LOCAL_PATH: /app/storage

volumes:
  nao-storage:
```

The port is bound to `127.0.0.1` on purpose: the reverse proxy in the next step is the only thing that should talk to the container from outside.

```bash theme={null}
docker compose up -d
docker compose logs -f
```

You should see `=== Starting Services ===` followed by the backend listening on 5005. `curl -I http://127.0.0.1:5005` returns a `200`.

<Info>
  If you built your own image with the context copied in (the `Dockerfile` from the Deployment Guide), replace `image: getnao/nao:latest` with your image and drop the `NAO_CONTEXT_*` variables in favour of `NAO_DEFAULT_PROJECT_PATH=/app/project`.
</Info>

### 1.5 Put HTTPS in front

The simplest way to terminate TLS on a single box is Caddy, which obtains and renews a Let's Encrypt certificate automatically. Point a DNS `A` record for `nao.your-company.com` at the Elastic IP first, then:

```bash theme={null}
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update
sudo apt-get install -y caddy
```

`/etc/caddy/Caddyfile`:

```text theme={null}
nao.your-company.com {
    reverse_proxy 127.0.0.1:5005
}
```

```bash theme={null}
sudo systemctl reload caddy
```

Open `https://nao.your-company.com`, confirm the chat UI loads, and complete the first sign-up. Make sure `BETTER_AUTH_URL` in `.env` matches this domain; if you changed it, run `docker compose up -d` again to restart with the new value.

If you prefer to terminate TLS on AWS rather than on the box, put an **Application Load Balancer** with an ACM certificate in front of the instance instead, forward to port 5005, and restrict `nao-ec2-sg` so that 5005 is only reachable from the ALB's security group.

### 1.6 Update nao

```bash theme={null}
cd ~/nao
docker compose pull
docker compose up -d
```

Sessions survive the restart because `BETTER_AUTH_SECRET` is fixed in `.env`, and data survives because it lives in RDS.

## Option 2: ECS on Fargate

Fargate runs the container without any instance to manage - the closest AWS equivalent to Cloud Run. You describe the container in a **task definition**, an **ECS service** keeps the desired number of tasks running, and an **Application Load Balancer** exposes it over HTTPS.

<Note>
  **AWS App Runner** is an even more Cloud Run-like service and does run the `getnao/nao` image, but it cannot attach a VPC security group to reach a private RDS instance without a VPC connector, and it offers less control over health checks and timeouts. ECS on Fargate is the recommended path.
</Note>

### 2.1 Store the secrets

Create one secret per sensitive value in **AWS Secrets Manager** (plain-text secrets, not key/value JSON), and note each ARN:

* `nao/DB_URI`
* `nao/BETTER_AUTH_SECRET`
* `nao/OPENAI_API_KEY`
* `nao/NAO_CONTEXT_GIT_SSH_KEY` (or `nao/NAO_CONTEXT_GIT_TOKEN`)
* one per warehouse credential referenced in `nao_config.yaml`

### 2.2 Create the IAM roles

Two roles are involved:

* **Task execution role** - used by ECS itself to pull the image and inject secrets. Start from the managed `AmazonECSTaskExecutionRolePolicy` and add permission to read your secrets:

```json theme={null}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["secretsmanager:GetSecretValue"],
      "Resource": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:nao/*"
    }
  ]
}
```

* **Task role** - used by nao at runtime. It needs nothing for a basic deployment. Grant it S3 permissions if you use the [S3 storage backend](/nao-agent/self-hosting/permanent-storage#s3-backend), which is the right choice on Fargate since task filesystems are ephemeral.

### 2.3 Create the cluster and the load balancer

1. **ECS** -> **Clusters** -> **Create cluster**, Fargate only, in the same VPC as RDS.
2. **EC2** -> **Load balancers** -> **Create Application Load Balancer**, internet-facing, in the public subnets of that VPC. Create a security group `nao-alb-sg` allowing inbound `80` and `443` from anywhere.
3. Add an **HTTPS:443** listener with a certificate from **ACM** for `nao.your-company.com`, and an **HTTP:80** listener that redirects to HTTPS.
4. Create a **target group** of type **IP**, protocol HTTP, port `5005`, health check path `/`. The HTTPS listener forwards to it.
5. Create a security group `nao-ecs-sg` for the tasks, allowing inbound `5005` **from `nao-alb-sg` only**.

### 2.4 Open the database to the tasks

On `nao-rds-sg`, add an inbound rule:

| Type       | Port | Source       |
| ---------- | ---- | ------------ |
| PostgreSQL | 5432 | `nao-ecs-sg` |

### 2.5 Write the task definition

Register this with **ECS** -> **Task definitions** -> **Create new task definition with JSON**, replacing account ID, region, and ARNs:

```json theme={null}
{
  "family": "nao",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "1024",
  "memory": "4096",
  "executionRoleArn": "arn:aws:iam::123456789012:role/nao-task-execution-role",
  "taskRoleArn": "arn:aws:iam::123456789012:role/nao-task-role",
  "containerDefinitions": [
    {
      "name": "nao",
      "image": "getnao/nao:latest",
      "essential": true,
      "portMappings": [{ "containerPort": 5005, "protocol": "tcp" }],
      "environment": [
        { "name": "DB_SSL", "value": "true" },
        { "name": "BETTER_AUTH_URL", "value": "https://nao.your-company.com" },
        { "name": "NAO_CONTEXT_SOURCE", "value": "git" },
        { "name": "NAO_CONTEXT_GIT_URL", "value": "git@github.com:your-org/your-nao-context.git" },
        { "name": "NAO_STORAGE_BACKEND", "value": "s3" },
        { "name": "NAO_STORAGE_S3_BUCKET", "value": "your-nao-storage" },
        { "name": "NAO_STORAGE_S3_REGION", "value": "eu-west-1" }
      ],
      "secrets": [
        { "name": "DB_URI", "valueFrom": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:nao/DB_URI" },
        { "name": "BETTER_AUTH_SECRET", "valueFrom": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:nao/BETTER_AUTH_SECRET" },
        { "name": "OPENAI_API_KEY", "valueFrom": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:nao/OPENAI_API_KEY" },
        { "name": "NAO_CONTEXT_GIT_SSH_KEY", "valueFrom": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:nao/NAO_CONTEXT_GIT_SSH_KEY" }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/nao",
          "awslogs-region": "eu-west-1",
          "awslogs-stream-prefix": "nao",
          "awslogs-create-group": "true"
        }
      }
    }
  ]
}
```

1 vCPU and 4 GB is a sensible starting size.

<Info>
  To use your own image instead of cloning at startup, push the image built from the Deployment Guide's `Dockerfile` to **Amazon ECR**, reference it in `"image"`, and replace the `NAO_CONTEXT_*` variables with `{ "name": "NAO_DEFAULT_PROJECT_PATH", "value": "/app/project" }`.
</Info>

### 2.6 Create the service

1. In the cluster, **Create service**: launch type Fargate, the `nao` task definition, desired tasks `1`.
2. **Networking**: the VPC's **private subnets**, security group `nao-ecs-sg`. If the private subnets have no NAT gateway, enable **public IP** so the task can reach the LLM API, Git, and Docker Hub - or add a NAT gateway.
3. **Load balancing**: attach the ALB and the target group from step 2.3. Set the **health check grace period** to `120` seconds so the task has time to clone the context and run migrations before the ALB starts judging it.
4. Create the service and wait for the task to reach `RUNNING` and the target to become `healthy`.

### 2.7 Point the domain at the ALB

In **Route 53** (or your DNS provider), create an alias `A` record for `nao.your-company.com` pointing at the ALB. Open the URL, confirm the chat UI loads, and complete the first sign-up.

If you deployed before having a domain, `BETTER_AUTH_URL` should be the ALB DNS name over `http://` for now. Update it to the final `https://` domain and register a new task definition revision - the service will roll the task.

### 2.8 Update nao

Register a new revision of the task definition (or simply **Update service** -> **Force new deployment** if you track `latest`) and ECS replaces the task. Because `BETTER_AUTH_SECRET` comes from Secrets Manager and data lives in RDS, users stay signed in through the rollout.

<Warning>
  Do not scale the service above one task without switching `NAO_STORAGE_BACKEND` to `s3`. With the default `local` backend each task has its own filesystem, so files saved by the agent would appear and disappear depending on which task serves a request. See [Permanent Storage](/nao-agent/self-hosting/permanent-storage).
</Warning>

## Option 3: Kubernetes / EKS

If you already run an EKS cluster, nao is a single `Deployment` plus a `Service` and an `Ingress`. The manifests below use the [AWS Load Balancer Controller](https://kubernetes-sigs.github.io/aws-load-balancer-controller/) for the ingress, which is the standard way to get an ALB with an ACM certificate on EKS. Any other ingress controller works too; only the annotations change.

### 3.1 Open the database to the cluster

On `nao-rds-sg`, add an inbound rule allowing PostgreSQL from the cluster:

| Type       | Port | Source                                                                |
| ---------- | ---- | --------------------------------------------------------------------- |
| PostgreSQL | 5432 | The EKS **cluster security group** (or the node group security group) |

The cluster security group ID is shown on the cluster's **Networking** tab in the EKS console. If your cluster uses [security groups for pods](https://docs.aws.amazon.com/eks/latest/userguide/security-groups-for-pods.html), reference the pod security group instead.

### 3.2 Create the namespace and the secrets

```bash theme={null}
kubectl create namespace nao

kubectl -n nao create secret generic nao-secrets \
  --from-literal=DB_URI='postgres://nao_admin:...@nao-db.abc123.eu-west-1.rds.amazonaws.com:5432/nao' \
  --from-literal=BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \
  --from-literal=OPENAI_API_KEY='sk-...' \
  --from-file=NAO_CONTEXT_GIT_SSH_KEY=./nao-deploy-key
```

Add one `--from-literal` per warehouse credential your `nao_config.yaml` reads from the environment.

<Warning>
  Run the `BETTER_AUTH_SECRET` generation **once**. If you recreate the secret from a script on every deploy, every rollout signs out all users. Prefer syncing it from AWS Secrets Manager with the [External Secrets Operator](https://external-secrets.io/) or the Secrets Store CSI driver so the value has a single home.
</Warning>

### 3.3 Deploy

`nao.yaml`:

```yaml theme={null}
apiVersion: v1
kind: ConfigMap
metadata:
  name: nao-config
  namespace: nao
data:
  DB_SSL: "true"
  BETTER_AUTH_URL: "https://nao.your-company.com"
  NAO_CONTEXT_SOURCE: "git"
  NAO_CONTEXT_GIT_URL: "git@github.com:your-org/your-nao-context.git"
  NAO_STORAGE_BACKEND: "s3"
  NAO_STORAGE_S3_BUCKET: "your-nao-storage"
  NAO_STORAGE_S3_REGION: "eu-west-1"
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: nao
  namespace: nao
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/nao-storage-role
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nao
  namespace: nao
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nao
  template:
    metadata:
      labels:
        app: nao
    spec:
      serviceAccountName: nao
      containers:
        - name: nao
          image: getnao/nao:latest
          ports:
            - containerPort: 5005
          envFrom:
            - configMapRef:
                name: nao-config
            - secretRef:
                name: nao-secrets
          resources:
            requests:
              cpu: 500m
              memory: 2Gi
            limits:
              memory: 4Gi
          startupProbe:
            httpGet:
              path: /
              port: 5005
            periodSeconds: 5
            failureThreshold: 36
          readinessProbe:
            httpGet:
              path: /
              port: 5005
            periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: nao
  namespace: nao
spec:
  selector:
    app: nao
  ports:
    - port: 80
      targetPort: 5005
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nao
  namespace: nao
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
    alb.ingress.kubernetes.io/ssl-redirect: "443"
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:eu-west-1:123456789012:certificate/xxxxxxxx
    alb.ingress.kubernetes.io/healthcheck-path: /
spec:
  ingressClassName: alb
  rules:
    - host: nao.your-company.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nao
                port:
                  number: 80
```

```bash theme={null}
kubectl apply -f nao.yaml
kubectl -n nao rollout status deployment/nao
kubectl -n nao get ingress nao
```

The startup probe allows three minutes for the container to clone the context and run migrations against RDS. Once the ingress shows an address, create an alias `A` record for `nao.your-company.com` pointing at that ALB hostname, open the URL, and complete the first sign-up.

The `ServiceAccount` annotation wires an [IAM role for service accounts](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) so nao can reach the S3 storage bucket without static keys; the role needs the [four S3 actions](/nao-agent/self-hosting/permanent-storage#required-permissions) on the bucket. Drop the annotation and the `NAO_STORAGE_*` entries if you want to start with `NAO_STORAGE_BACKEND=none`.

<Info>
  To run your own image with the context baked in, push it to ECR, set `image:` accordingly, and replace the `NAO_CONTEXT_*` entries in the `ConfigMap` with `NAO_DEFAULT_PROJECT_PATH: "/app/project"`.
</Info>

### 3.4 Update nao

```bash theme={null}
kubectl -n nao set image deployment/nao nao=getnao/nao:<version>
```

or, if you track `latest`, `kubectl -n nao rollout restart deployment/nao`. Pin a version tag in production so a rollout is a deliberate action.

### Running more than one replica

Scaling `replicas` above `1` works, with two conditions:

* `NAO_STORAGE_BACKEND` must be `s3` (or `local` on a `ReadWriteMany` volume such as EFS). See [Permanent Storage](/nao-agent/self-hosting/permanent-storage#local-backend).
* `BETTER_AUTH_SECRET` must be the same across pods, which it is as long as it comes from the shared `Secret`.

## Troubleshooting

| Symptom                                                                    | Likely cause                                                                                                       |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Container exits with `connection timed out` or `ETIMEDOUT` to the RDS host | Missing inbound `5432` rule on the RDS security group from the compute's security group, or RDS in a different VPC |
| `no pg_hba.conf entry ... no encryption`                                   | `DB_SSL` is not set to `true`                                                                                      |
| Users are signed out after every deploy or restart                         | `BETTER_AUTH_SECRET` is not set, or is regenerated on each deploy                                                  |
| Redirect loop or "invalid origin" after sign-in                            | `BETTER_AUTH_URL` does not match the URL in the browser (wrong scheme, wrong host, trailing path)                  |
| ALB target stays `unhealthy`                                               | Health check grace period too short, or the task security group does not allow `5005` from the ALB security group  |
| `ERROR: nao_config.yaml not found`                                         | `NAO_CONTEXT_GIT_URL` points at the wrong repo or branch, or `NAO_CONTEXT_GIT_SUBPATH` is needed                   |
| Task cannot pull the image or reach the LLM API                            | Private subnets without a NAT gateway and without a public IP on the task                                          |

## Next steps

<Card title="Deployment Guide" icon="server" href="/nao-agent/self-hosting/deployment-guide">
  Context repository setup, SMTP for email, first sign-up, and post-deploy customisation
</Card>

<Card title="Permanent Storage" icon="database" href="/nao-agent/self-hosting/permanent-storage">
  Configure the S3 backend and IAM permissions for agent file storage
</Card>

<Card title="Admin Setup" icon="user-shield" href="/nao-agent/chat/admin/setup">
  Invite users and configure Google, GitHub, or SAML sign-in
</Card>
