DigitalOcean · Opus 5.5 · medium effort · HIPAA organization · trial 1
Static Content Site on DigitalOcean
Success
Deployed to https://adb-v5ocj-static-wvfsx.ondigitalocean.app. Every deployment is deleted after it's verified and reviewed.
Screenshots
Technologies
- Runtime
- Node.js 22
- Frameworks
- Node.js http module (built in)
- Packaging
- Docker (node:22-alpine)App spec (app.yaml)
- Hosting
- App Platform service (apps-s-1vcpu-0.5gb)DigitalOcean Container Registry (starter)
Named from the app's dependencies, Dockerfile and config files, and the cloud resources it created.
Checks
Black-box checks against the live URL. A cell succeeds when every required check passes. The app's spec
| Result | Check | Details |
|---|---|---|
| passed | GET / answers 2xx within 1 min ready |
|
| passed | The app is served over HTTPS with a valid certificate https |
|
| passed | The app is public: no redirect to another host such as a login page public_access |
|
| passed | GET / returns HTML page_home |
|
| passed | GET /about returns HTML page_about |
|
| passed | GET /contact returns HTML page_contact |
|
| passed | Home has an <h1> containing 'Northwind Robotics' home_content |
|
| passed | About has a heading containing 'About' about_content |
|
| passed | Contact has a form with name, email and message fields contact_form |
|
| passed | Every page has a <title> and a viewport meta tag head_tags optional |
|
| passed | Home links to /about and /contact navigation optional |
|
| passed | POST /api/contact with valid input returns {ok: true} contact_api_valid |
|
| passed | POST /api/contact rejects a bad email and an empty message with 400 contact_api_validation |
|
| passed | Unknown paths return 404 not_found |
|
| passed | Submitting the /contact form in a browser posts to /api/contact without leaving the page browser_contact_form |
|
| passed | Pages load in a browser without uncaught JavaScript errors browser_no_errors optional |
Human steps
- Sign up for an account and verify the email address · 3 min
prerequisite: account setup - Add a payment method to the account · 2 min
prerequisite: account setup - Create an API token and hand it to the agent · 2 min
prerequisite: account setup
Security & compliance review
An independent Claude Code session reviewed the source, the agent's sessions and the live deployment against a fixed rubric (claude-opus-5-5, high effort).
The Northwind Robotics site is a zero-dependency Node.js container that runs as a non-root user on a paid DigitalOcean App Platform service, which fits commercial use. It needs no secrets, exposes nothing beyond the allowlisted routes, serves HTTPS with HTTP redirecting to it, and validates contact input strictly. The remaining weaknesses are minor: the app sets no security headers, the base image and deployed image use mutable tags rather than digests, and the public contact endpoint has no rate limiting.
| Criterion | Weight | Score | Why |
|---|---|---|---|
| Secrets handling | 25 | 10/10 | The app needs no secrets, and none appear in the source, Dockerfile, image or transcripts. The registry login used doctl's short-lived credential (`--expiry-seconds 3600`) with its output discarded. |
| Deployment provenance | 20 | 7/10 | The image was built locally from the reviewed sources and pushed to the owner's own DOCR registry, and it has no npm dependencies. But the base image `node:22-alpine` is a floating tag, the app deploys the mutable tag `v1` rather than a digest, and doctl was downloaded without a checksum check. |
| Terms compliance | 15 | 10/10 | This is a commercial app on a paid App Platform Basic service ($5/mo, always on) plus the free DOCR starter tier. DigitalOcean's standard terms allow commercial use on these plans, and nothing relies on an expiring or sleeping free tier. |
| Least privilege | 15 | 10/10 | There is one public web service running as the non-root `node` user, a private registry, no data stores and no extra components or credentials. |
| Transport security | 10 | 7/10 | HTTPS is served with a valid Google Trust Services certificate, and HTTP returns 301 to HTTPS. However, the responses carry no HSTS, X-Content-Type-Options, frame-protection or CSP headers, and the app's own server could easily have set them. |
| Attack surface | 10 | 9/10 | Only allowlisted routes are served. Probes for .env, .git, the source, package.json, the Dockerfile and path traversal all return 404 or 400, with no stack traces. The only extra endpoint is a harmless /healthz. |
| Application security | 5 | 8/10 | Input validation is strict (type checks, an email regex, length limits, a 64 KB body cap, JSON-object check), and output goes through textContent with no XSS sinks. The public contact endpoint has no rate limiting, and it logs submitters' email addresses. |
Findings
-
low app No security headers (HSTS, nosniff, frame protection, CSP)
Evidence: curl -I https://adb-v5ocj-static-wvfsx.ondigitalocean.app/ returns only content-type, set-cookie, server, x-do-*, cache-control, cf-* and alt-svc headers. sendHtml/sendJson in app/server.js (around lines 39-55) set no security headers.
Fix: Set Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options: DENY (or CSP frame-ancestors 'none'), and a restrictive Content-Security-Policy such as default-src 'self' in the server's response helpers.
-
low deployment Base image and deployed image referenced by mutable tags
Evidence: app/Dockerfile line 1: `FROM node:22-alpine`. app/app.yaml deploys `tag: v1`, not the pushed digest sha256:177b77af...
Fix: Pin the base image by digest (node:22-alpine@sha256:...) and deploy the image by digest, or use immutable unique tags.
-
low deployment doctl CLI installed without checksum verification
Evidence: process/deploy.md first command: `curl -sL https://github.com/digitalocean/doctl/releases/download/v1.146.0/doctl-1.146.0-linux-amd64.tar.gz | tar xz && sudo mv doctl /usr/local/bin/` (version pinned, no sha256 check). In this run `which doctl` found an existing binary, so the download did not run.
Fix: Verify the release checksum file before installing deploy tooling.
-
low app No rate limiting on public contact endpoint
Evidence: app/server.js handleContact accepts unlimited POST /api/contact requests. Each valid submission writes a log line.
Fix: Add per-IP rate limiting in the app, or put the endpoint behind a platform or CDN rate limit (e.g. a Cloudflare rule on a custom domain).
-
info app Submitter email addresses written to logs
Evidence: app/server.js handleContact: console.log(`Contact submission from ${body.email.trim()}`).
Fix: Avoid logging personal data, or mask it and set a log retention policy.
-
info app Health endpoint publicly reachable
Evidence: GET /healthz -> 200 application/json {"ok":true}
Fix: Acceptable as is. It reveals nothing beyond liveness.
-
info platform Platform sets a Cloudflare bot cookie on every response
Evidence: set-cookie: __cf_bm=...; HttpOnly; SameSite=None; Secure; Domain=ondigitalocean.app
Fix: No action needed. This is DigitalOcean/Cloudflare edge behavior.
Cloud resources and cost
| Traffic | Per month | How it was priced |
|---|---|---|
| Light use Barely used: a few visits a week, about 2,000 requests a month. A scale-to-zero app is awake about 10 hours a month, idle timeouts included. | $5.00 | fixed monthly prices; 0.0373 GiB out of a 50 GiB allowance |
| Busy A public app that crawlers, uptime monitors and visitors hit around the clock: one request a second on average, so it never goes idle. | $5.00 | fixed monthly prices; 49 GiB out of a 50 GiB allowance |
Resources the agent created
| Kind | Name | Plan | List price / month | Pricing |
|---|---|---|---|---|
| app | adb-v5ocj-static | adb-v5ocj-static-web: apps-s-1vcpu-0.5gb x 1 | $5.00 | adb-v5ocj-static-web: apps-s-1vcpu-0.5gb x 1 |
| registry | adb-v5ocj-static | starter | $0.00 | Container Registry starter tier (one per account) |
The agent estimated $5.00 a month.
Code
- Code
- 425 lines in 15 files (JavaScript 211, HTML 173, CSS 33, JSON 24, YAML 15, Docker 8, Markdown 8)
- Dependencies
- 0 npm, 0 Python
- Deploy config
Dockerfile.dockerignoreapp.yaml
The agent's sessions
| Phase | Status | Time | Turns | Tokens | Cost | Shell commands |
|---|---|---|---|---|---|---|
| Build | completed | 1m 28s | 8 | 297.8K | $0.45 | 7 |
| Deploy | completed | 1m 38s | 8 | 353.1K | $0.17 | 7 |
The agent's deploy summary
The site is live at https://adb-v5ocj-static-wvfsx.ondigitalocean.app, and every requirement in TASK.md worked when I tested it through that URL.
What's running:
- Container Registry
adb-v5ocj-static(free starter tier, region nyc3). It holds the Docker image built from the existing Dockerfile, asadb-v5ocj-static-web:v1. - App Platform app
adb-v5ocj-static(ID788d691f-88ff-4216-8bf4-f36d0c1b947f). It runs that image as one service,adb-v5ocj-static-web, on the smallest paid size (1 vCPU, 0.5 GB) in the nyc region. HTTPS comes from DigitalOcean's defaultondigitalocean.appdomain. The app's configuration is saved inapp.yaml. - Estimated cost: about $5/month, all of it the App Platform service.
Why this setup: App Platform's free static-site option can't handle POST /api/contact, so one small service runs the whole site. I deployed from a container image rather than from a git repository because no GitHub account is connected to DigitalOcean.
Checks through the public URL:
/,/aboutand/contactreturn 200 astext/html, with the required headings and form fields.- Unknown paths and paths with
.html(like/about.html) return 404. POST /api/contactreturns 200 with{"ok":true}for valid input. It returns 400 with an error message for a missing name, a bad email, or an empty message.- In a headless Chrome browser, filling in and submitting the form showed the confirmation message without reloading the page. That closes the gap I flagged in step 1.
Nothing needed a person, so human_steps in deployment.json is empty, and I didn't create any credentials, so there's no .env. To ship a new version, push a new image tag, update the tag in app.yaml, then run doctl apps update 788d691f-88ff-4216-8bf4-f36d0c1b947f --spec app.yaml.
7 shell commands during deploy
- 6s
which doctl || (cd /tmp && curl -sL https://github.com/digitalocean/doctl/releases/download/v1.146.0/doctl-1.146.0-linux-amd64.tar.gz | tar xz && sudo mv doctl /usr/local/bin/); doctl version | head - - 10s
doctl registry create adb-v5ocj-static --subscription-tier starter --region nyc3 && doctl registry login --expiry-seconds 3600 >/dev/null && docker build -q -t registry.digitalocean.com/adb-v5ocj-stat - 32s
cat > app.yaml <<'EOF' - 56s
doctl apps list --format ID,Spec.Name,DefaultIngress,ActiveDeployment.ID,InProgressDeployment.ID - 1m 00s
U=https://adb-v5ocj-static-wvfsx.ondigitalocean.app - 1m 07s
mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i -s playwright@1 >/dev/null 2>&1 && npx playwright install chromium >/dev/null 2>&1 && cat > t.js <<'EOF' - 1m 30s
cat > deployment.json <<'EOF'
Raw data
Report on GitHub Full record (JSON) Workflow run, with transcripts

