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.
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.
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.
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.
-
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 -
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 -
Configure
Copy the example env file and fill in your accounts. Leave
DRY_RUN=truefor 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 -
Start it
mkdir -p ~/.aws ~/.kube data # bind-mount sources must exist docker compose up -d --build docker compose logs -fOpen
http://<host>:8080, press Run scan. -
Upgrade later
docker compose pull && docker compose up -d # findings, approvals and the audit log live in ./data and survive
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.
-
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 -
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.txtInstall
kubectltoo if you want Kubernetes cost allocation; CloudPilot shells out to it and inherits its kubeconfig. -
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 -
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 -
Check it
curl -s localhost:8080/health | python3 -m json.tool journalctl -u cloudpilot -f
-
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 -
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>" -
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 -
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 -
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-serverif 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
persistence.enabled=false
a restart loses your history — the chart defaults it on.
-
Create your workspace
Sign up with a work email. You become the administrator for your organisation and can invite colleagues afterwards.
-
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
CloudPilotReadOnlyand nothing else to start. EKS clusters are discovered through this same role — Kubernetes is not a separate connection. -
Paste the role ARN
Add the account in Settings → AWS accounts. The first scan runs straight away; findings appear within a few minutes.
-
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.
-
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-hosted | Self-hosted | |
|---|---|---|
| Who runs the process | ZeroLatch | You |
| Where findings are stored | Our database, isolated per organisation | Your disk |
| AWS access | Cross-account role trusting us, with an external ID | Instance profile or a role you assume locally |
| Outbound network needed | None from your account | HTTPS to the licence server |
| Upgrades | Automatic | docker compose pull or helm upgrade |
| Kubernetes access | Through EKS in the same role | In-cluster ServiceAccount, or your kubeconfig |
| Users & roles | Same | Same |
| Price | Same | Same |
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.
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"]
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.