Nov 9, 20269 min read

HIPAA-Ready Backend on AWS: Keep PHI in Your Own Account

How to run a healthcare API with patient data in your own AWS account: Postgres for records, object storage for documents, authenticated endpoints and least-privilege IAM, with Encore or by hand.

A healthcare backend is only useful if patient data stays inside an account your compliance program can point at. Clinics and digital-health teams usually land on Amazon RDS for records and Amazon S3 for documents, because those services are on AWS's HIPAA-eligible services list once the account has a Business Associate Agreement. The hard part is wiring the API, the database, the bucket and the IAM role so every piece of PHI stays in that account and every endpoint that touches it requires an authenticated user.

This guide builds a small patient-records API on AWS: authenticated reads and writes to Postgres, document uploads to object storage, and an audit row for every change. It covers the hand-built AWS layout first, then the same shape declared in application code with Encore.

What does a HIPAA-ready backend need?

PieceRoleTypical AWS service
Relational dataPatient demographics, encounters, notes metadataAmazon RDS for PostgreSQL
DocumentsPDFs, images, export bundlesAmazon S3
SecretsSigning keys, third-party API tokensSecrets Manager or an app vault
ComputeAlways-on APIECS Fargate or EKS
IdentityWho called the APIYour IdP plus an auth handler
AuditWho read or changed whatApplication table plus CloudTrail

AWS documents how to architect for HIPAA security and compliance on those services. The BAA, encryption settings, logging and access reviews stay with your team and AWS regardless of how you provision the resources.

How do you build it by hand on AWS?

The plain-AWS shape is a VPC with private subnets for data, public subnets for a load balancer, and no direct internet path to the database.

  1. Network: Create a VPC, two private subnets across availability zones for RDS, and an Application Load Balancer in public subnets. Put the API tasks in private subnets with a NAT gateway for outbound calls to your IdP.
  2. Database: Create an encrypted Amazon RDS for PostgreSQL instance in the private subnets, with a security group that accepts connections only from the API task security group. Turn on automated backups and ship logs to CloudWatch.
  3. Documents: Create a private S3 bucket with Block Public Access on, default SSE-S3 or SSE-KMS encryption, and a bucket policy that denies non-TLS access. The API's IAM task role gets s3:PutObject and s3:GetObject on that bucket ARN only.
  4. Compute: Build a container image, push it to ECR, and run it as an ECS Fargate service behind the load balancer. Store signing keys in AWS Secrets Manager and grant the task role secretsmanager:GetSecretValue on those secrets only.
  5. Identity and audit: Validate bearer tokens in middleware against your IdP (Cognito, Auth0 or Clerk). Write an audit_events row on every PHI read and write, and enable CloudTrail on the account for control-plane changes.

A minimal handler that writes a patient row with the AWS SDK for Go v2 looks like this once the RDS connection is open:

// Hand-rolled: same insert the Encore sample performs, against a pool you configured. func createPatient(ctx context.Context, db *sql.DB, actor string, p Patient) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var id int64 if err := tx.QueryRowContext(ctx, ` INSERT INTO patients (external_id, full_name, dob, owner_uid) VALUES ($1, $2, $3::date, $4) RETURNING id `, p.ExternalID, p.FullName, p.DOB, actor).Scan(&id); err != nil { return err } if _, err := tx.ExecContext(ctx, ` INSERT INTO audit_events (actor_uid, action, patient_id, detail) VALUES ($1, 'create_patient', $2, $3) `, actor, id, p.ExternalID); err != nil { return err } return tx.Commit() }

Terraform or CloudFormation usually owns the VPC, RDS, S3, ECS and IAM pieces, and every new environment repeats that module set. Local development means Docker Compose for Postgres or a shared staging account, which is where PHI often lands outside the account your BAA covers.

How do you build it with Encore?

Encore

Encore combines an open source infrastructure SDK with a platform that provisions and deploys into your own AWS or GCP account. For a healthcare API that means the Postgres database, the object storage bucket and the secrets the service needs are declared next to the handlers that use them, and authenticated APIs gate every path that returns PHI.

Application code passes through Encore to infrastructure in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create clinic-api --example=hello-world$ cd clinic-api && mkdir -p patients/migrations

The migration keeps patient rows and an append-only audit log in the same database:

-- patients/migrations/1_create_patients.up.sql CREATE TABLE patients ( id BIGSERIAL PRIMARY KEY, external_id TEXT NOT NULL UNIQUE, full_name TEXT NOT NULL, dob DATE NOT NULL, owner_uid TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE audit_events ( id BIGSERIAL PRIMARY KEY, actor_uid TEXT NOT NULL, action TEXT NOT NULL, patient_id BIGINT REFERENCES patients(id), detail TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now() );

The service declares the database, a private documents bucket and a signing secret, then exposes only auth-gated APIs:

// patients/patients.go package patients import ( "context" "fmt" "time" "encore.dev/beta/auth" "encore.dev/beta/errs" "encore.dev/storage/objects" "encore.dev/storage/sqldb" ) // Postgres locally with `encore run`, Amazon RDS when deployed to AWS. var db = sqldb.NewDatabase("patients", sqldb.DatabaseConfig{ Migrations: "./migrations", }) // Clinical documents: S3 on AWS, a local bucket with `encore run`. var Documents = objects.NewBucket("patient-documents", objects.BucketConfig{ Public: false, }) // Signing key for short-lived document URLs. Values are not stored in the repo. var secrets struct { DocumentSigningKey string AuthToken string // local/dev bearer; replace with IdP verification in production } // AuthHandler validates the bearer token from your IdP. // Swap the body for Clerk, Auth0 or Firebase; see /docs/go/develop/auth. // //encore:authhandler func AuthHandler(ctx context.Context, token string) (auth.UID, error) { // Local/dev: compare to a secret. In production, verify a JWT from your IdP // (signature, issuer, audience, expiry, revocation) and return the subject. if token == "" || token != secrets.AuthToken { return "", &errs.Error{Code: errs.Unauthenticated, Message: "invalid token"} } return auth.UID("user"), nil } type Patient struct { ID int64 `json:"id"` ExternalID string `json:"external_id"` FullName string `json:"full_name"` DOB string `json:"dob"` // YYYY-MM-DD CreatedAt time.Time `json:"created_at"` } type CreatePatientParams struct { ExternalID string `json:"external_id"` FullName string `json:"full_name"` DOB string `json:"dob"` } // CreatePatient stores a patient row and writes an audit event. // //encore:api auth method=POST path=/patients func CreatePatient(ctx context.Context, p *CreatePatientParams) (*Patient, error) { uid, exists := auth.UserID() if !exists { return nil, &errs.Error{Code: errs.Unauthenticated, Message: "login required"} } var out Patient err := db.QueryRow(ctx, ` INSERT INTO patients (external_id, full_name, dob, owner_uid) VALUES ($1, $2, $3::date, $4) RETURNING id, external_id, full_name, to_char(dob, 'YYYY-MM-DD'), created_at `, p.ExternalID, p.FullName, p.DOB, string(uid)).Scan( &out.ID, &out.ExternalID, &out.FullName, &out.DOB, &out.CreatedAt, ) if err != nil { return nil, err } if _, err := db.Exec(ctx, ` INSERT INTO audit_events (actor_uid, action, patient_id, detail) VALUES ($1, 'create_patient', $2, $3) `, string(uid), out.ID, p.ExternalID); err != nil { return nil, err } return &out, nil } // GetPatient returns one patient. Requires auth so PHI is never public. // //encore:api auth method=GET path=/patients/:id func GetPatient(ctx context.Context, id int64) (*Patient, error) { uid, exists := auth.UserID() if !exists { return nil, &errs.Error{Code: errs.Unauthenticated, Message: "login required"} } var out Patient err := db.QueryRow(ctx, ` SELECT id, external_id, full_name, to_char(dob, 'YYYY-MM-DD'), created_at FROM patients WHERE id = $1 AND owner_uid = $2 `, id, string(uid)).Scan(&out.ID, &out.ExternalID, &out.FullName, &out.DOB, &out.CreatedAt) if err != nil { return nil, &errs.Error{Code: errs.NotFound, Message: "patient not found"} } if _, err := db.Exec(ctx, ` INSERT INTO audit_events (actor_uid, action, patient_id, detail) VALUES ($1, 'read_patient', $2, '') `, string(uid), out.ID); err != nil { return nil, err } return &out, nil } type UploadURLResponse struct { Key string `json:"key"` } // RequestUpload reserves an object key for a clinical document upload. // //encore:api auth method=POST path=/patients/:id/documents func RequestUpload(ctx context.Context, id int64) (*UploadURLResponse, error) { uid, exists := auth.UserID() if !exists { return nil, &errs.Error{Code: errs.Unauthenticated, Message: "login required"} } var existsRow int if err := db.QueryRow(ctx, `SELECT 1 FROM patients WHERE id = $1 AND owner_uid = $2`, id, string(uid)).Scan(&existsRow); err != nil { return nil, &errs.Error{Code: errs.NotFound, Message: "patient not found"} } key := fmt.Sprintf("patients/%d/%s.pdf", id, time.Now().UTC().Format("20060102T150405")) // Upload bytes with Documents.Upload in a follow-up call or raw endpoint. // DocumentSigningKey is available here when you mint a short-lived URL. _ = secrets.DocumentSigningKey if _, err := db.Exec(ctx, ` INSERT INTO audit_events (actor_uid, action, patient_id, detail) VALUES ($1, 'request_document_upload', $2, $3) `, string(uid), id, key); err != nil { return nil, err } return &UploadURLResponse{Key: key}, nil }

AuthHandler accepts only the AuthToken secret (set with encore secret set), not an arbitrary bearer string. Swap it for a Clerk, Auth0 or Cognito JWT check from the auth docs before production. Every //encore:api auth endpoint then refuses unauthenticated calls before your handler runs. GetPatient and document routes also require owner_uid to match the caller, so an authenticated user cannot read another patient by ID.

With encore run, Postgres starts locally and the bucket is a local directory, so you can create a patient, read it back and write an audit row without an AWS account. On deploy, Encore provisions RDS Postgres and an S3 bucket in your AWS account. Encore provisions an IAM role for the service. Secret values are set per environment in the dashboard and are never committed, so coding agents that edit the repo cannot read production keys from source.

Best fit: healthcare and digital-health teams that need PHI in their own AWS account, with the database, document storage and authenticated APIs declared next to the handlers that use them.

Teams that want that shape in their own AWS account can install Encore, declare the database, documents bucket and auth-gated APIs in Go, and deploy into RDS, S3 and an IAM role without a separate infrastructure project. HIPAA compliance stays with your team and AWS.

Compliance boundary

Encore's security and compliance page describes controls aligned with the SOC 2 trust service criteria and a SOC 2 Type I target of Q4 2026. It does not list a HIPAA certification or a completed SOC 2 report as of that page's last update (June 24, 2026). Encore holds no HIPAA certification or attestation; it provisions those resources in your account, and HIPAA compliance stays with your team and AWS. Sign the AWS BAA, configure GuardDuty and CloudTrail, and run the access reviews your program requires.

Frequently asked questions

What makes a backend HIPAA-ready on AWS?

PHI must stay in an AWS account covered by a Business Associate Agreement with AWS, on HIPAA-eligible services such as Amazon RDS for PostgreSQL and Amazon S3, with encryption, access logging and least-privilege IAM. The application layer still needs authenticated APIs, audit trails and controls your compliance program defines.

How do I build a healthcare backend on AWS with Encore?

Declare a Postgres database with sqldb.NewDatabase, a documents bucket with objects.NewBucket, secrets for signing keys, and auth-gated APIs with //encore:api auth. Connect your AWS account, create an environment and git push encore. Encore provisions RDS, S3 and an IAM role for the service in that account. Encore holds no HIPAA certification or attestation; it provisions those resources in your account, and HIPAA compliance stays with your team and AWS.

Where does patient data live when the API is on AWS?

In your AWS account: Postgres as Amazon RDS and documents as Amazon S3, inside your VPC and compliance boundary. Application traffic for those stores does not need to pass through a third-party host. Secret values for signing keys should stay out of the repo so coding agents working in the codebase cannot read them from source.

Do I still need a Business Associate Agreement?

For PHI on AWS you still sign a BAA with AWS covering the HIPAA-eligible services you use. Your own product also needs the policies, access reviews and audit process your compliance program requires.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders