Portfolio|Case Study
Personal project

AWS Ops Dashboard

A self-service AWS dashboard replacing repetitive console work: EC2 lifecycle, security group management, cost estimation and browser-based SSH.

ReactTypeScriptPythonFlaskboto3TerraformparamikoWebSocketAWS

The problem

A lot of a systems administrator's day is the same handful of AWS console journeys, repeated. Launch a test box. Open a port so a colleague can reach it. Work out what the estate is costing before someone asks. Get a shell on an instance quickly, which in practice means finding the right key, finding the right IP, and hoping the local SSH client is configured.

None of these are difficult. They are just slow, and the console makes each one a multi-page journey with the important information spread across tabs. The cost of that isn't only time — it's that infrastructure gets created by clicking, so nothing is written down, and a week later nobody can say why an instance exists.

This project collapses those journeys into one page, and makes the creation path go through Terraform so that what gets built is described as code.


What it does

EC2 lifecycle

List every instance with state, type and public IP. Start, stop and terminate. Provision new instances through a guided modal.

Plan before apply

Every provision runs terraform plan first and shows the diff, with add/change/destroy counts and a warning when anything would be destroyed. Runs are async jobs with streamed output.

Scheduled start/stop

Cron schedules in any timezone, targeting instances by tag so they survive machines being replaced. Previewable and dry-runnable before they ever fire.

Security groups

Full create, read, update and delete against live security groups, including editing individual ingress and egress rules.

Cost Explorer

Month-to-date, last month, a month-end forecast, daily trend, and breakdowns by service and by tag — including what share of spend nobody owns. Cached, because the API bills per request.

Insights

Unattached volumes and idle Elastic IPs with a cost attached, untagged and long-lived instances, sensitive ports open to the world, and Terraform drift in both directions.

Browser SSH

A WebSocket relay backed by paramiko gives an interactive shell against any running instance, with no local SSH client needed.

Auth and RBAC

Pluggable authentication — local users with argon2 hashes and rotating JWTs, or OIDC bearer tokens validated against an identity provider's JWKS. Three roles enforced server-side.

Session recording

Every browser SSH session is written to an asciicast file, replayable in the dashboard. Terminal output is captured, not keystrokes — so a password typed at a prompt that suppresses echo is never recorded.

Audit trail

Every state-changing action is written to an append-only log with actor, role, target, source IP and outcome. Denied attempts are recorded too.

Tested

182 tests against a mocked EC2, covering role enforcement on every endpoint, token replay, Terraform job rollback, schedule due-time logic, insight false-positive cases, asciicast validity, and Cost Explorer caching. No AWS account needed and nothing is billed.


Architecture

Browser
React + TypeScript
Vite, Tailwind, shadcn/ui
REST →WS →
Flask API
15 endpoints
flask-sock
SSH relay
boto3 →paramiko →
EC2 & SGs
lifecycle, rules, cost
Terraform
provisioning

Two paths reach AWS. Read and lifecycle operations go through boto3 directly, because they are idempotent and need to be fast. Creation goes through Terraform, because it needs to leave a record. The SSH terminal is a third path entirely — the backend holds the paramiko session and relays raw bytes over a WebSocket to xterm.js in the browser.


Technical decisions

Why put authentication behind an interface rather than picking one?

An ops team on Entra ID or Okta needs OIDC; a small team or a local evaluation needs something that runs offline with no identity provider at all. Both backends produce the same principal object, so routes, the role model and the audit log are written once and do not know which is in use. The cost is one abstraction; the benefit is that the demo runs anywhere and the production path is real rather than aspirational.

Why re-read the role from storage on every request?

The role is in the JWT, so trusting it would save a lookup. But then revoking someone's access does nothing until their token expires — up to fifteen minutes where a demoted or dismissed user still has admin. Reading the role per request makes revocation immediate, which is what anyone auditing the system will actually ask about. Disabling a user revokes their refresh tokens too, so the session cannot be renewed.

Why a ticket for the SSH WebSocket instead of the bearer token?

A browser cannot set an Authorization header on a WebSocket handshake, and putting a long-lived access token in the query string writes it into every proxy and server log along the path. Instead the authenticated REST API mints a ticket that is single-use, expires in sixty seconds, and is bound to one instance ID. A leaked ticket is worth almost nothing; a leaked access token is worth fifteen minutes of full access.

Why do session recordings capture output rather than keystrokes?

Recording keystrokes is the obvious implementation and it is the wrong one. It captures the password someone types at a sudo prompt, which turns the recording store into a credential store — the exact thing the feature is supposed to make safer. Recording the output stream instead still shows every command that ran, because the remote echoes what is typed, but a prompt that suppresses echo produces nothing to record. It is a smaller capture that answers the same question, and it means a leaked recording is embarrassing rather than catastrophic. Recordings remain admin-only, size-capped, retention-managed, and reading one is itself an audited event.

Why does the insights scan only recommend, never act?

Every finding it produces looks obviously safe to auto-remediate and isn't. An unattached volume might be the only copy of something; an instance stopped for a month might be a licence-bound build box; a wide-open port might be deliberate for a public service. A tool that deletes things based on a heuristic gets switched off after the first false positive, and the report loses its value entirely. Recommending keeps it trustworthy, and the estimated monthly cost per finding gives the human a way to prioritise rather than a reason to stop reading.

Why are Terraform runs jobs rather than HTTP requests?

An apply routinely takes minutes. Holding a request open for that long occupies a worker thread doing nothing and exceeds the idle timeout of most reverse proxies, so the operator sees a gateway error while the apply is still running — the worst possible outcome, because they do not know whether it worked. Runs are jobs: the caller gets an id immediately and polls, output is appended as it is produced, and a failed apply rolls the desired state back so it never claims resources that were not created.

Why target scheduled actions by tag rather than by instance ID?

An instance ID is only meaningful until the instance is replaced, and in an estate managed as code they get replaced often. A schedule bound to IDs quietly stops covering things and nobody notices until the bill arrives. Tags survive replacement, so a schedule written once keeps working. The trade-off is that a tag typo matches nothing silently, which is why schedules can be previewed and dry-run before they ever fire.

Why Terraform rather than calling boto3 to create instances?

boto3 already handles start, stop and terminate. Creation is different: an instance that gets created by an API call exists only in AWS, but an instance created through Terraform exists in state, can be planned before it is applied, and can be destroyed cleanly. Shelling out to terraform apply keeps the dashboard from becoming a second, undocumented source of truth for infrastructure.

Why a WebSocket relay instead of a hosted terminal service?

The browser cannot open a TCP connection to port 22 directly. The backend holds the paramiko session and relays bytes over a WebSocket, which means the private key never leaves the server and the browser never needs an SSH implementation. It also keeps the whole thing self-contained — no third-party terminal service in the path.

Why keep a local cost estimate alongside Cost Explorer?

They answer different questions. Cost Explorer reports what was actually billed, but it lags by up to a day and charges a cent per API request — so an uncached dashboard polling it every fifteen seconds would cost around $60 a day to tell you what you are spending. The local estimate is a price table keyed on instance type: instantly wrong to the cent, instantly right about whether something is running that shouldn't be. Keeping both means the fast question stays free and the accurate question stays accurate, and neither pretends to be the other.

How do you make a per-request-billed API safe to put behind a dashboard?

Cache it, and make the cost of the cache visible. Responses are held for six hours, because Cost Explorer only refreshes a few times a day and anything shorter pays to re-fetch identical numbers. Every response says whether it was served from cache, and the panel shows the running billed-request count and what it has cost. Clearing the cache is an admin action, because refilling it spends money. The failure modes are treated as expected states rather than errors: a missing permission returns 503 naming the exact IAM actions required, and an account where Cost Explorer has not finished initialising says so instead of showing zero.


Limitations

This is a lab and portfolio project, and some defaults reflect that. They are documented in the repository rather than left to be discovered:

  • The audit log shares the application database. Shipping it to an external store would be needed to make it genuinely tamper-evident.
  • Port 22 is open to 0.0.0.0/0 by default so the browser terminal works from anywhere. A ssh_allowed_cidr variable exists to narrow it.
  • Terraform state is local, so the dashboard assumes a single operator. Shared use would need a remote backend with locking.
  • The scheduler runs in-process. Behind a load balancer only one instance may run it, or schedules fire once per process. Scaling out would need leader election or an external trigger.
  • Cost estimation uses a local price table for on-demand Linux in one region, not the AWS Pricing API. Types outside the table are reported as unpriced rather than counted as free.
  • Single region and single account per deployment, and the module uses the default VPC.

Stack

FrontendReact 18, TypeScript, Vite, Tailwind CSS, shadcn/ui
BackendPython, Flask, flask-cors, flask-sock
AWS SDKboto3
SSHparamiko over WebSocket
ProvisioningTerraform (AWS provider ~> 5.0)

The full source, setup instructions and API reference are on GitHub.

View repository