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.
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.
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.
s3:PutObject and s3:GetObject on that bucket ARN only.secretsmanager:GetSecretValue on those secrets only.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.
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.

Run in your terminal to get started locally.
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.
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.
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.
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.
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.
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.