
"Cleaned" Is Not "Safe": A Water Ledger on AWS IoT
How we built DHARA: an ESP32 probe on AWS IoT Core with X.509 identity, IoT rules to S3 and EC2, and SHA-256 evidence, proven in a live recorded run.
"Cleaned" Is Not "Safe": Building a Heat-Aware Water Evidence Ledger with AWS IoT Core, Amazon S3 and Amazon EC2
How we turned a paper register into an auditable pipeline, from an in-tank probe to a warden's signature, and why the most important feature is the one that refuses to say "safe".
Abstract
Hostels, schools and campuses across India store drinking water in rooftop tanks. The usual record of a tank's condition is one line in a maintenance register: "12 March: drained and scrubbed. Cleaned." That line cannot say how warm the water is today, whether any disinfectant is left, who checked the work, or what a laboratory found. Summer heat makes the gap matter more.
We built DHARA (Hindi for "stream"), a heat-aware water evidence ledger. An ESP32 + DS18B20 probe publishes tank-water temperature to AWS IoT Core over MQTT/TLS, with a per-device X.509 identity. Two IoT rules then process every message: one archives the raw payload in Amazon S3, and the other forwards it through a confirmed HTTPS destination to a validating ingest API running on Amazon EC2.
On top of that pipeline, DHARA ranks which tank to test next with a transparent rule engine and fingerprints maintenance evidence with SHA-256. It also enforces a separation of duties: the person who did the work can never approve it.
We verified the AWS path end to end in a live, recorded run, using a simulated ESP32 on Wokwi in place of physical hardware. This article covers:
- the problem, with its sources;
- the architecture and the security decisions behind it;
- the rule engine and the evidence model;
- the bugs that only real AWS exposed;
- the limits we chose to state rather than hide.
1. The problem: a record that can't see inside the tank
1.1 Scale and stakes
NITI Aayog's Composite Water Management Index (June 2018) puts the problem plainly: inadequate access to safe water costs India about two lakh lives every year. That figure covers far more than rooftop tanks, but it sets the stakes. Water that reaches an institution in good condition can still degrade in storage.
1.2 Chlorine residual is the protection that has to survive storage
India's drinking water specification, BIS IS 10500:2012, sets a minimum of 0.2 mg/L free residual chlorine, tested at the consumer end. It rises to 0.5 mg/L where protection against viral infection is required. WHO Technical Note 11 gives a similar range: 0.2–0.5 mg/L at the point where the consumer collects water. It also explains why the residual matters: chlorine's residual can protect the supply downstream from the disinfection point. Storage is exactly that "downstream" place.
1.3 Heat makes the residual harder to keep
A 2024 critical review in Water Research (Furst et al., doi:10.1016/j.watres.2024.121913) makes two points:
- "maintaining protective residuals can be challenging at high temperatures."
- Heat transfer to water also happens during storage in above-ground tanks.
The review is careful, and so are we. It notes that chlorine decay rates rise sharply with temperature in some waters but not in others. So DHARA does not predict chlorine from temperature. It uses heat as a reason to measure sooner, which is a claim the evidence supports.
1.4 Evidence is scattered, and that is the operational failure
When a parent, an auditor or a warden asks what was checked, the institution usually has three things:
- a register that says cleaned;
- photos in a technician's phone gallery, with no link to the record;
- a lab report sitting in an email thread, unreviewed.
No one can reconstruct who measured what, when, with which method, who reviewed it, and what was still open. That is the problem DHARA solves.
2. Design principles
We set four principles before writing any code. Each one shows up later in the architecture.
- Separate the kinds of truth. Measurement, maintenance, evidence and review answer different questions, and each has limits. Merging them into one "safety score" creates false confidence, so DHARA never computes one.
- Rules you can read, not a model you must trust. Every prioritisation decision shows the exact rule and threshold that fired. Thresholds are configuration, defaulting to IS 10500.
- Identity at every hop. The device proves who it is to AWS with a certificate. The reading proves who sent it to DHARA with a token. A person proves who they are before acting, and the system records it.
- Honest by design. DHARA shows what was measured, what was done and what still needs doing. It never claims water is safe to drink.
Concept | Question it answers | What it cannot establish by itself |
|---|---|---|
Measurement | How warm? How much chlorine? When, by what method? | That the whole tank is safe at all times |
Maintenance | What work was recorded, for which date? | That the work was done correctly |
Evidence | Which photos and documents support it? | That a photo is genuine or shows this tank |
Review | Who decided, when and why? | Independent verification of physical work |
3. Architecture
Plain text
ESP32 + DS18B20 ──MQTT/TLS, X.509──▶ AWS IoT Core├─ rule dhara_archive ──▶ Amazon S3 (raw archive, token removed)└─ rule dhara_forward ──▶ HTTPS destination ──▶ DHARA on Amazon EC2(validate · de-duplicate · audit)Technician / warden browser ──HTTPS──▶ DHARA ──▶ Amazon S3 (private evidence, 300-second presigned URLs)Anyone with the tank's QR code ───────▶ DHARA public status page (read-only)
3.1 Why AWS IoT Core instead of posting straight to the app
A probe could call our HTTPS API directly, and our firmware supports that as a bench-test mode. For production we route through AWS IoT Core for three reasons:
- Device identity that isn't a shared secret. Each probe is an IoT thing with its own X.509 certificate. If one device is lost, the rest of the fleet is unaffected, and a certificate can be revoked on its own.
- Fan-out without changing firmware. One publish feeds both the archive and the application. Adding a third consumer later is a rule change, not a firmware update in hundreds of tanks.
- A raw record independent of the application. The S3 archive is written by AWS, not by DHARA. If our ingest logic has a bug, the original payloads are still there.
3.2 Least-privilege device policy
Each device may connect only as itself and publish only to its own topic, using IoT Core policy variables:
JSON
{"Version": "2012-10-17","Statement": [{"Effect": "Allow", "Action": "iot:Connect","Resource": "arn:aws:iot:REGION:ACCOUNT_ID:client/${iot:Connection.Thing.ThingName}"},{"Effect": "Allow", "Action": "iot:Publish","Resource": "arn:aws:iot:REGION:ACCOUNT_ID:topic/dhara/${iot:Connection.Thing.ThingName}/telemetry"}]}
A probe cannot subscribe to other tanks' data, cannot pose as another device, and cannot publish anywhere else.
3.3 Two rules, two jobs
dhara_archive writes one object per message to S3. The SQL explicitly selects fields, so the device token never reaches the archive:JSON
{"sql": "SELECT readings, topic(2) AS thing_name, timestamp() AS received_ms FROM 'dhara/+/telemetry'","actions": [{"s3": {"roleArn": "arn:aws:iam::ACCOUNT_ID:role/dhara-iot-s3-archive","bucketName": "BUCKET","key": "dhara/iot-raw/${topic(2)}/${parse_time('yyyy/MM/dd', timestamp())}/${newuuid()}.json"}}]}
The IAM role behind this rule can do exactly one thing:
s3:PutObject on dhara/iot-raw/* .dhara_forward sends the full message ( SELECT * FROM 'dhara/+/telemetry' ) to an HTTPS rule destination pointing at DHARA's ingest endpoint. Destinations must be confirmed before they deliver: AWS sends a one-time confirmation request, and DHARA's ingest view recognises it and logs the token. We then confirm it with aws iot confirm-topic-rule-destination .There's a detail the documentation is easy to misread on: a confirmed destination starts
DISABLED , so you also need aws iot update-topic-rule-destination --status ENABLED . Until we enabled it, the rule accepted messages and silently forwarded nothing.3.4 Two layers of authentication
AWS authenticates the device with its certificate. DHARA then authenticates the reading with a per-device token, of which it stores only the SHA-256 digest. The token is shown once at registration and never again. This means:
- a copied IoT certificate cannot write readings into the wrong tank;
- a copied DHARA token cannot connect to IoT Core.
3.5 The ingest API treats probe data like manual data
Every probe reading goes through the same validation as a reading a technician types in:
- an ISO-8601 timestamp with a UTC offset (timestamps without a time zone are rejected);
- a value within a plausible range for tank water;
- a limit on readings per request;
- idempotency: a reading is unique per
(device, kind, measured_at), so IoT retries create duplicates that are counted, never stored twice;
- every accepted batch writes an audit event naming the device and the tank.
Python
_, made = TankObservation.objects.get_or_create(device=device, kind=TankObservation.Kind.WATER_TEMP, measured_at=measured_at,defaults=dict(equipment=device.equipment, value=canonical, entered_value=value,entered_unit='°C', method='probe', sample_point='in-tank probe'))
The firmware also refuses to send without real time. It waits for NTP and drops the DS18B20's sentinel values: −127 °C means a disconnected probe, and 85 °C is the power-on default.
3.6 Amazon S3 for evidence
Maintenance photos, documents and lab reports go to the same private bucket under a separate prefix. The bucket is set up as follows:
- Block Public Access is on (all four settings);
- object ownership is
BucketOwnerEnforced, so ACLs are disabled;
- SSE-S3 encryption is on by default;
- versioning is enabled;
- object keys are generated (
dhara/evidence/<institution>/<uuid>.<ext>), never user-supplied.
Downloads go through DHARA's per-institution access check first, then a 300-second presigned URL. If an S3 write fails, the database transaction rolls back and the user sees "nothing was saved", so there is never a record pointing at a missing file.
3.7 Amazon EC2 deployment
DHARA (Django 5.2, gunicorn, Docker) runs on a
t3.micro in ap-south-1 (Mumbai), so Indian institutions' data stays in India:- Caddy terminates TLS with a Let's Encrypt certificate, and HTTP redirects to HTTPS.
- There is no SSH port. Admin and deploys go through AWS Systems Manager Run Command: the deploy script uploads a release archive to S3 and the instance pulls and rebuilds it.
- IMDSv2 is required, and the container gets AWS credentials only from the instance role, scoped to the app's S3 prefixes.
4. Testing priority: deciding which tank to measure next
Heat doesn't tell you chlorine is low. It tells you the last measurement is aging faster than you'd like. DHARA's prioritiser is a deliberately simple, ordered rule list; the first matching chlorine rule wins:
# | Condition | Result |
|---|---|---|
1 | No free-chlorine reading on record | Scheduled test: medium, or high with heat context |
2 | Latest reading older than 72 h | Scheduled test: medium, or high with heat. A stale low reading triggers a re-test, never an alarm |
3 | Fresh reading below 0.2 mg/L | Warden review: urgent, confirm with a repeat measurement |
4 | Fresh reading above 1.0 mg/L | Warden review: medium |
5 | Fresh reading within 0.1 mg/L of the minimum | Repeat measurement: medium, or high with heat |
6 | Heat context and the reading is older than 24 h | Repeat measurement: medium |
7 | Sun-exposed tank, forecast at or above 35 °C, no recent water-temperature reading | Scheduled test (temperature): low |
Heat context means the Open-Meteo forecast maximum air temperature is ≥ 35 °C, or a fresh tank-water reading is ≥ 25 °C. That 25 °C default matches the WHO advice cited in the Water Research review to keep drinking water below 25 °C to limit microbial growth.
Heat only raises urgency; it never creates a "below threshold" finding on its own. Every boundary is explicit and unit-tested:
- "below" is strict (exactly 0.2 is not below);
- "fresh" is inclusive (≤ 72 h);
- the temperature thresholds are inclusive (≥).
What the engine does not do matters as much:
- it doesn't project chlorine decay;
- it doesn't call a reading proof of potability;
- it doesn't tell anyone to chlorinate or isolate a tank.
A warden decides, and records the decision through a follow-up.
5. Evidence you can check later
When a technician submits maintenance with before and after photos:
- The server validates each file: extension allow-list, size limit, and a real content check (an image must decode, a PDF must have a PDF header). The storage name is generated, never taken from the user.
- DHARA computes a SHA-256 digest from the bytes it received and stores it with the file's metadata.
- Anyone with access can press Re-check digest. DHARA recomputes the hash from the stored object and confirms whether it still matches.
- The event goes to a review queue. The submitter can't review their own work. A warden accepts it, rejects it or asks for more evidence, and must give a reason for rejecting or asking for more. Reviewed events are never edited; a correction is a new event that references the old one.
- Unmet schedules, missing lab reports and urgent readings open follow-ups with an owner and a due date. A task closes only when someone records what was done.
We're explicit about what this proves and what it doesn't:
Mechanism | Useful for | Not proof of |
|---|---|---|
Server submission time | When DHARA received the record | When the work physically happened |
SHA-256 digest | Detecting later changes to the stored file | That the photo is genuine or shows this tank |
Human review | Who decided what, when and why | Independent verification of the work |
Audit table | A readable trail of actions | Tamper-proofing: it's an ordinary database table |
Finally, every tank gets a QR code. Scanning it opens a read-only status page (maintenance status, the latest lab report and open documentation) with no login and no private details.
6. A thermal model that has to earn the right to forecast
For tanks with a probe, DHARA fits a small heat-balance model per tank, for each hour k:
T[k+1] − T[k] = a·G[k] + b·(Tair[k] − T[k]) + c
where
G is solar radiation and Tair is air temperature (both from Open-Meteo) and T is the measured water temperature. It's linear in a, b, c, so ordinary least squares solves it transparently.The model is shown only if it validates:
- It is fitted on the first 75% of hourly steps in time order.
- It is free-run over the remaining 25%, which it never saw, from a single measured starting value.
- It is rejected if a < 0, if b falls outside (0, 1), or if the held-out mean absolute error is above 1.5 °C.
Forecasts never extend past the validated span. The same model measures whether a cooling intervention (reflective paint, shade, insulation) actually lowered the daily peak, with a 95% interval, instead of assuming it did.
7. Proof: the live, end-to-end run
We had no ESP32 hardware before the deadline, so we used Wokwi , a browser ESP32 simulator, running the same publish logic. The device is simulated; everything after it is real AWS.
On 11 October 2026:
- We provisioned the thing
dhara-wokwi-01, its certificate, the policy above, both rules, the IAM role and the HTTPS destination, using the AWS CLI.
- The simulated board, with its probe set to 34.2 °C, joined Wokwi's virtual Wi-Fi, synced NTP and published every 5 seconds over MQTT/TLS on port 8883.
- Pollers ran in parallel: one listed the S3 archive prefix about every 1.5 seconds, and one queried DHARA's database on EC2 through Systems Manager. Both logged epoch timestamps.
The result:
- each message produced a new S3 object about every 5 seconds;
- DHARA's database received the same readings (34.19 °C, source
dhara-wokwi-01);
- across all our test runs, the archive holds 115 objects for this device.
The demo video shows both panels filling in sync with the simulator's serial log, at their real UTC times.
8. Things only real AWS taught us
Presigned URLs and regional buckets. Our mocked S3 tests were green (moto), but the first test against the real bucket in ap-south-1 failed with 403 on download:
- the presigned URLs were signed for the global S3 endpoint;
- buckets outside us-east-1 answer with a 307 redirect to the regional host;
- the redirected request no longer matched the signature.
The fix was to sign for the regional S3 endpoint, with virtual-hosted addressing. Lesson: test against the real service early. A mock can't reproduce where a request gets routed.
Confirmed is not enabled. As described in section 3.3, an HTTPS rule destination needs both confirmation and an explicit
ENABLED status.Never trust a probe's first reading. The DS18B20 reports 85 °C at power-on and −127 °C when disconnected. Firmware that publishes those values creates false alarms that undermine trust in the whole system.
Secrets stay out of the record. The archive rule selects fields explicitly, so device tokens never land in S3. In the database, tokens exist only as SHA-256 digests.
9. Results
Check | Result |
|---|---|
Automated tests | 88 green: workflow, permissions, evidence, priority rules, thermal twin, ingest, S3 via moto (one live-S3 test skips without credentials) |
AWS IoT Core end to end | Simulated ESP32 → MQTT/TLS + X.509 → rules → S3 archive + DHARA on EC2 |
Live S3 test | Upload, read back, SHA-256 verify, presigned URL 200, plain URL 403, cleanup: all green |
EC2 deployment | HTTPS 200, HTTP → HTTPS redirect, port 22 closed, IMDSv2 required |
Cost profile | One t3.micro (about $0.018/hour with its Elastic IP) plus pay-per-message IoT Core; a probe every 5 minutes is about 8,600 messages a month |
10. Limits we state on purpose
- The probe is simulated. The AWS path is verified; physical hardware is not. The simulated reading is a set value, not a tank measurement.
- DHARA records what people submit. It can't confirm that work happened, that a photo is genuine or that a lab report is valid. SHA-256 detects later changes, not staged photos.
- The audit log is not tamper-proof. S3 Object Lock and external anchoring are on the roadmap.
- Testing priority ranks measurement work. It doesn't predict chlorine and never declares water safe. DPD field test kits have limited precision.
- The live demo uses SQLite on a persistent volume (single instance), not Amazon RDS.
We think these limits are a feature. In public health tooling, over-claiming is a failure mode, and a system that says "we don't know yet, measure this tank next" earns more trust than one that shows a green tick.
11. What's next
- Flash the firmware on a physical ESP32 and log a real rooftop tank through a summer.
- Amazon RDS for PostgreSQL, and S3 Object Lock for evidence retention.
- Scheduled reminders, and a Hindi interface.
- A pilot with one consenting institution, measuring review turnaround and follow-up closure against its paper process. We will make no outcome claims until that pilot has data.
Try it
- Live app: running on Amazon EC2 in ap-south-1 (address in the project README)
- AWS IoT setup, step by step:
deploy/aws/iot/README.md
- Run the probe without hardware:
firmware/wokwi/sketch_aws.ino+diagram.jsonin a new Wokwi ESP32 project
Water deserves more than a record.
References
- NITI Aayog. Composite Water Management Index. June 2018.
- Bureau of Indian Standards. IS 10500:2012 Drinking Water — Specification (second revision).
- World Health Organization. Technical Note 11: Measuring chlorine levels in water supplies. Updated July 2013.
- Furst, Graham, Weisman and Adusei. Critical review on heat and drinking water distribution systems. Water Research, 2024. doi:10.1016/j.watres.2024.121913.
- AWS documentation: AWS IoT Core policies and policy variables; AWS IoT rules and HTTPS rule destinations; Amazon S3 presigned URLs.
Built for the WeMakeDevs × AWS Environmental hackathon 2026, Heat and Water track. Team: @Aaditya1273, @utkarsh684. Developed with Claude Code (Anthropic) as an AI coding assistant under the team's direction and review.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article