Deployment

Three ways to run it. All reach the same dashboard.

CloudPilot is self-hosted: it runs in your account, next to the infrastructure it measures. Pick whichever fits how you already run things.

No infrastructure

Cloud-hosted — we run it

Sign up, create one cross-account IAM role, and you are reading your costs in minutes. We handle upgrades, backups and uptime. Your AWS credentials stay in your account: we assume a role you create, with an external ID, and you choose whether that role is read-only.

Your infrastructure

Self-hosted — you run it

Runs on your EC2 or inside your cluster. Cost and inventory data never leaves your VPC; the only outbound call is a licence check carrying aggregate counts. Pick this when policy says the data stays put, or when your accounts have no public egress at all.

Same product either way. Identical detection rules, remediation, reports and pricing. The difference is who operates the process and where the database lives — and you can move between them later, since the licence key is the same.

Self-hosted: pick a runtime

EC2 + Docker Compose

The usual choice. One t3.small, ten minutes, upgrades are a pull and a up -d.

EC2 + systemd

No container runtime. A virtualenv and a unit file, for hosts where Docker is not allowed.

Kubernetes + Helm

Runs in the cluster it is costing. IRSA for AWS, in-cluster ServiceAccount for Kubernetes.

Before any of them: create the IAM role CloudPilot will use. Start with the read-only policy — it can report but cannot change anything. Add the remediation policy later, once you trust the findings.
  1. Launch the host

    A t3.small (2 vCPU / 2 GB) handles a few hundred resources. Attach the instance profile carrying your CloudPilot role, and open port 8080 only to trusted source IPs — there is no login page.

    sudo dnf install -y docker git
    sudo systemctl enable --now docker
    sudo usermod -aG docker ec2-user   # log out and back in
  2. Install the compose plugin

    sudo mkdir -p /usr/local/lib/docker/cli-plugins
    sudo curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m) \
      -o /usr/local/lib/docker/cli-plugins/docker-compose
    sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
  3. Configure

    Copy the example env file and fill in your accounts. Leave DRY_RUN=true for the first week.

    cp .env.example .env
    
    # .env
    DEMO_MODE=false
    CLOUDPILOT_API_KEY=$(openssl rand -hex 32)
    CLOUDPILOT_ACCOUNTS='[{"id":"111122223333","name":"prod",
      "role_arn":"arn:aws:iam::111122223333:role/CloudPilotRole",
      "external_id":"...","regions":["ap-south-1","us-east-1"]}]'
    
    # Licensing — paste the key in the dashboard instead if you prefer
    LICENSE_SERVER_URL=https://license.zerolatch.in
    LICENSE_PUBLIC_KEY=<pin the key from /api/license/pubkey>
    LICENSE_KEY=ZLCP-XXXXX-XXXXX-XXXXX-XXXXX
    
    DRY_RUN=true
    ALLOW_DESTRUCTIVE=false
  4. Start it

    mkdir -p ~/.aws ~/.kube data   # bind-mount sources must exist
    docker compose up -d --build
    docker compose logs -f

    Open http://<host>:8080, press Run scan.

  5. Upgrade later

    docker compose pull && docker compose up -d
    # findings, approvals and the audit log live in ./data and survive
Put TLS in front of it. The compose file binds to 127.0.0.1 by default. Terminate TLS with nginx or an ALB, or reach it over a VPN or SSM port-forward rather than exposing 8080.
  1. Install the runtime

    sudo dnf install -y python3.12 python3.12-pip git
    sudo useradd --system --home /opt/cloudpilot --shell /sbin/nologin cloudpilot
    sudo mkdir -p /opt/cloudpilot /var/lib/cloudpilot
    sudo chown -R cloudpilot:cloudpilot /opt/cloudpilot /var/lib/cloudpilot
  2. Install CloudPilot

    sudo -u cloudpilot tar -xzf cloudpilot-0.1.0.tar.gz -C /opt/cloudpilot
    cd /opt/cloudpilot
    sudo -u cloudpilot python3.12 -m venv .venv
    sudo -u cloudpilot .venv/bin/pip install -r requirements.txt

    Install kubectl too if you want Kubernetes cost allocation; CloudPilot shells out to it and inherits its kubeconfig.

  3. Write the environment file

    sudo install -m 600 -o cloudpilot -g cloudpilot /dev/null /etc/cloudpilot.env
    sudo tee /etc/cloudpilot.env >/dev/null <<'EOF'
    DEMO_MODE=false
    DB_PATH=/var/lib/cloudpilot/cloudpilot.db
    CLOUDPILOT_API_KEY=replace-me
    CLOUDPILOT_ACCOUNTS=[{"id":"111122223333","name":"prod","regions":["ap-south-1"]}]
    LICENSE_SERVER_URL=https://license.zerolatch.in
    LICENSE_PUBLIC_KEY=replace-me
    LICENSE_KEY=ZLCP-XXXXX-XXXXX-XXXXX-XXXXX
    DRY_RUN=true
    EOF
  4. Create the unit

    sudo tee /etc/systemd/system/cloudpilot.service >/dev/null <<'EOF'
    [Unit]
    Description=ZeroLatch CloudPilot
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    Type=exec
    User=cloudpilot
    Group=cloudpilot
    WorkingDirectory=/opt/cloudpilot
    EnvironmentFile=/etc/cloudpilot.env
    ExecStart=/opt/cloudpilot/.venv/bin/uvicorn main:app \
      --host 127.0.0.1 --port 8080 --workers 1
    Restart=on-failure
    RestartSec=5
    
    # Hardening: it needs its database directory and nothing else.
    NoNewPrivileges=true
    PrivateTmp=true
    ProtectSystem=strict
    ProtectHome=true
    ReadWritePaths=/var/lib/cloudpilot
    ProtectKernelTunables=true
    ProtectControlGroups=true
    RestrictSUIDSGID=true
    
    [Install]
    WantedBy=multi-user.target
    EOF
    
    sudo systemctl daemon-reload
    sudo systemctl enable --now cloudpilot
    sudo systemctl status cloudpilot
  5. Check it

    curl -s localhost:8080/health | python3 -m json.tool
    journalctl -u cloudpilot -f
One process only. The scan scheduler and the SQLite writer both assume a single worker. Scale the instance, not the worker count.
  1. Give it an AWS identity (IRSA)

    So the pod can read Cost Explorer and your inventory without static credentials.

    eksctl create iamserviceaccount \
      --cluster prod-ap-south-1 \
      --namespace cloudpilot \
      --name cloudpilot \
      --attach-policy-arn arn:aws:iam::111122223333:policy/CloudPilotReadOnly \
      --approve
  2. Create the secret

    kubectl create namespace cloudpilot
    
    kubectl -n cloudpilot create secret generic cloudpilot \
      --from-literal=api-key="$(openssl rand -hex 32)" \
      --from-literal=license-key="ZLCP-XXXXX-XXXXX-XXXXX-XXXXX" \
      --from-literal=license-public-key="<from /api/license/pubkey>"
  3. Install the chart

    helm upgrade --install cloudpilot ./deploy/helm/cloudpilot \
      --namespace cloudpilot \
      --set image.tag=0.1.0 \
      --set serviceAccount.name=cloudpilot \
      --set config.demoMode=false \
      --set config.dryRun=true \
      --set-json 'config.accounts=[{"id":"111122223333","name":"prod","regions":["ap-south-1"]}]' \
      --set persistence.size=4Gi
  4. Reach the dashboard

    kubectl -n cloudpilot port-forward svc/cloudpilot 8080:8080
    # or expose it through your ingress:
    #   --set ingress.enabled=true \
    #   --set ingress.host=cloudpilot.internal.example.com
  5. In-cluster cost allocation

    The chart's ServiceAccount already carries the read ClusterRole, so CloudPilot reads nodes, pods and metrics with no kubeconfig. Install metrics-server if you have not — without it, allocation falls back to requests only and overprovisioning cannot be detected.

    kubectl top nodes   # must work before allocation is meaningful
Use a PersistentVolumeClaim. Findings, approvals and the audit log live in SQLite on disk. With persistence.enabled=false a restart loses your history — the chart defaults it on.
Nothing to install. The only thing you create is an IAM role in your account that trusts ours. You can revoke it at any time and CloudPilot immediately goes blind — there is no agent left behind.
  1. Create your workspace

    Sign up with a work email. You become the administrator for your organisation and can invite colleagues afterwards.

  2. Create the cross-account role

    In the AWS account you want to watch, create a role that trusts the ZeroLatch account and carries the read-only policy. The external ID is shown in your dashboard and is unique to you — it stops anyone else's workspace naming your role.

    {
      "Version": "2012-10-17",
      "Statement": [{
        "Effect": "Allow",
        "Principal": { "AWS": "arn:aws:iam::ZEROLATCH_ACCOUNT_ID:role/CloudPilotCloud" },
        "Action": "sts:AssumeRole",
        "Condition": {
          "StringEquals": { "sts:ExternalId": "<the external ID from your dashboard>" }
        }
      }]
    }

    Attach CloudPilotReadOnly and nothing else to start. EKS clusters are discovered through this same role — Kubernetes is not a separate connection.

  3. Paste the role ARN

    Add the account in Settings → AWS accounts. The first scan runs straight away; findings appear within a few minutes.

  4. Invite your team

    Under Users, add colleagues as read or write and grant each of them the AWS accounts they should see. A new member starts with no accounts until you grant them.

  5. Enable remediation when you are ready

    Add the remediation policy to the same role and turn dry run off. Until you do, CloudPilot can only report — it has no permission to change anything, whatever anyone clicks.

How the two differ

 Cloud-hostedSelf-hosted
Who runs the processZeroLatchYou
Where findings are storedOur database, isolated per organisationYour disk
AWS accessCross-account role trusting us, with an external IDInstance profile or a role you assume locally
Outbound network neededNone from your accountHTTPS to the licence server
UpgradesAutomaticdocker compose pull or helm upgrade
Kubernetes accessThrough EKS in the same roleIn-cluster ServiceAccount, or your kubeconfig
Users & rolesSameSame
PriceSameSame

Two policies, deliberately separate

Attach the first one and CloudPilot physically cannot change anything. Add the second only when you set DRY_RUN=false.

CloudPilotReadOnly start here

ce:GetCostAndUsage, ec2:Describe*, rds:Describe*, s3:List* / GetBucket*, elasticloadbalancing:Describe*, eks:Describe*, cloudwatch:GetMetricData, organizations:ListAccounts.

CloudPilotRemediation

ec2:StopInstances / StartInstances / ModifyInstanceAttribute / ModifyVolume / CreateSnapshot, rds:StopDBInstance / ModifyDBInstance, eks:UpdateNodegroupConfig, s3:PutLifecycleConfiguration.

Destructive actions are a third, separate decision. TerminateInstances, DeleteSnapshot, ReleaseAddress, DeleteNatGateway and DeleteLoadBalancer cannot be undone. Leave them out of the policy unless you have also set ALLOW_DESTRUCTIVE=true.

Cross-account access

For an AWS Organization, create CloudPilotRole in each member account trusting the account CloudPilot runs in, with an ExternalId you keep secret:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::HUB_ACCOUNT_ID:role/CloudPilotHubRole" },
    "Action": "sts:AssumeRole",
    "Condition": { "StringEquals": { "sts:ExternalId": "your-secret-string" } }
  }]
}

Kubernetes RBAC

Read access for allocation; the write verbs only if you want CloudPilot to patch workloads.

rules:
  - apiGroups: [""]
    resources: ["nodes", "pods", "namespaces", "events"]
    verbs: ["get", "list"]
  - apiGroups: ["apps"]
    resources: ["deployments", "statefulsets", "daemonsets", "replicasets"]
    verbs: ["get", "list"]
  - apiGroups: ["metrics.k8s.io"]
    resources: ["pods", "nodes"]
    verbs: ["get", "list"]
Rollout

Look first. Act second. Automate last.

Week 1 — look

Read-only IAM, DRY_RUN=true. Read the findings, dismiss what does not apply. Dismissals stick across scans.

Week 2 — act, reversibly

Add the remediation policy, set DRY_RUN=false, keep destructive off. Start with gp2→gp3: online, reversible, roughly 20% off every gp2 volume.

Later — automate the boring ones

Add AUTO_APPROVE_ACTIONS for the low-risk reversible actions you have watched work. Destructive actions are never auto-approved.