TrayPage Docs

Self-hosting on AWS

Two ways to run TrayPage in your own AWS account (one EC2 instance, or Fargate + RDS), how they differ, and the key steps.

This page explains how to run TrayPage in your own AWS account, so your data stays there. There are two setups, and the templates for both (CloudFormation or Terraform) live under deploy/aws/ in the repository. This page covers how to choose and the key steps. The full parameter and variable lists and the detailed commands are in each README.

How the two setups differ

Minimal (one EC2 instance)Standard (Fargate + RDS)
Built withCloudFormation (deploy/aws/cloudformation)Terraform (deploy/aws/terraform)
Good forTrying it out, small use, workloads that can tolerate downtimeProduction, where downtime is a problem
LayoutOne EC2 instance runs Caddy, the app, and PostgresFargate tasks (2 by default) behind an ALB, with the database on RDS
RedundancyNone (one server)Spread across multiple AZs. RDS is Multi-AZ by default
Database backupsDaily EBS snapshots (14 kept by default) plus a daily pg_dumpRDS automated backups (7 days by default)
Operational effortMedium (updates are done on the instance)Medium to high (you manage Terraform)
Rough costAbout $25 per monthAbout $200 per month for the default setup, about $125 for the small setup

The minimal setup's cost is an estimate for the default configuration in the Tokyo region, and varies with region and instance type. The main components are EC2 (t4g.small) at about $16, 40 GiB of EBS at about $4, and the Elastic IP (public IPv4) at about $3.6, plus a small amount for S3 and backups.

The standard setup's cost is an estimate for the Tokyo region (ap-northeast-1) at on-demand prices. It varies with data transfer and request volume. The breakdown:

  • The NAT Gateway is about $45. It carries outbound traffic such as pulling the image and sending email.
  • Making RDS Multi-AZ exactly doubles the instance and storage cost.
  • The small setup uses one Fargate task and a single-AZ db.t4g.micro RDS instance. It is meant for evaluation and has no redundancy.

Check exact amounts with the AWS Pricing Calculator.

A reasonable path is to try the minimal setup first and create the standard setup when you move to production.

Prerequisites for both setups

  • Two domain names: one for the app and one for the viewer. The viewer host serves arbitrary HTML, so it must be a different host name from the app. If possible, use separate domains (for example traypage.example.com and traypage-content.example.net). See Security notes for the reason.
  • A way to send email: sign-in uses an emailed link. You can use SES (SMTP) or Resend. SES requires verifying the sender domain and requesting production access (leaving the sandbox). Until that is approved, login emails reach only addresses you have verified in SES.
  • An AWS account and permissions: for the minimal setup, permission to create CloudFormation, EC2, IAM, and S3 resources. For the standard setup, permission to create VPC, ECS, RDS, S3, IAM, Secrets Manager, SES, ACM, and Route 53 resources.
  • An ACM certificate (standard setup): the ALB uses a certificate that covers both host names. If you have a Route 53 hosted zone, Terraform creates it, including the DNS validation. Without a hosted zone, prepare a certificate in the same region as the ALB and pass it as certificate_arn. In that case you point DNS at the ALB yourself.

In the minimal setup, once the DNS A records point at the server, Caddy gets certificates for both host names from Let's Encrypt automatically.

Minimal setup (one EC2 instance): key steps

  1. Create the CloudFormation stack, specifying AppDomain, ViewerDomain, and the email settings. You can use the AWS console or the CLI.
  2. When the stack completes, point A records for both host names at the ElasticIp output.
  3. Confirm that https://<app host>/health responds and that the login email arrives. The first boot takes a few minutes. The app applies database migrations automatically at startup.

The security group opens only ports 80 and 443. SSH is not used. To get onto the server, use SSM Session Manager.

Backups, updates, and deletion (minimal setup)

  • Backups: database data lives on EBS, which is snapshotted daily. A daily pg_dump is also saved to a backup S3 bucket. Page content is protected by S3 versioning.
  • Updates: changing TraypageImage and updating the stack does not take effect, because the startup script runs only on first boot. Log in to the instance, change the image tag in .env, and run docker compose pull and up -d. Pin the tag to a version such as v1.2.3.
  • Deletion: deleting the stack leaves the two S3 buckets in place. Review their contents and delete them by hand. The data EBS volume also leaves a snapshot behind.

Putting CloudFront or a load balancer in front

Authentication actions are rate-limited per client IP address. If you add CloudFront or similar in front of the EC2 instance, everyone is counted as the same IP and hits the limit together. In that case, configure trusted_proxies in Caddy. See the Configuration reference.

Standard setup (Fargate + RDS): key steps

  1. Prepare the prerequisites: Terraform 1.5 or later, AWS credentials, two host names, DNS and a certificate, and an SES sender domain.
  2. Create a variables file from the bundled example (examples/standard). The required variables are image_tag, app_hostname, viewer_hostname, and email_from. If you use Route 53, also set route53_zone_id.
  3. Run terraform init, terraform plan, and terraform apply. The first run takes 20 to 30 minutes. For production, keep the state in an encrypted S3 backend, because it contains values such as the database password.
  4. Leave the SES sandbox, then check /health, sign-in, and the MCP connection.

The app creates and updates the database tables itself at startup. Terraform only creates an empty database and a user, so a deployment involves no database work. A task whose migration fails does not start, and ECS rolls back to the previous task definition.

Updates, backups, and deletion (standard setup)

  • Updates: set image_tag to the new version and run terraform apply. ECS replaces tasks by stopping the old ones only after the new ones are healthy. Take an RDS snapshot before updating. Migrations cannot be reversed, so to go back to an older version you restore the database from the snapshot.
  • Backups: use RDS automated backups (7 days by default) and manual snapshots. Page content is protected by S3 versioning.
  • Deletion: the ALB and RDS have deletion protection. Turn it off, then run terraform destroy. RDS leaves a final snapshot. An S3 bucket that still has contents fails to delete, so change the setting and apply it first only if you are sure the contents can go.

No keys for S3

In both setups, the S3 bucket for page content is accessed without access keys. The EC2 instance role or the Fargate task role is granted permission for that bucket only. The app obtains the role's temporary credentials and uses them to access S3. This is switched on by the environment variable S3_USE_IAM_ROLE=1, which the templates set for you. No secret needs to be stored on the server.

The one exception is SES SMTP in the standard setup: SES requires an SMTP password derived from an IAM user's key. Terraform creates an IAM user allowed to do nothing but send email, and passes the password to the app through Secrets Manager.