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 with | CloudFormation (deploy/aws/cloudformation) | Terraform (deploy/aws/terraform) |
| Good for | Trying it out, small use, workloads that can tolerate downtime | Production, where downtime is a problem |
| Layout | One EC2 instance runs Caddy, the app, and Postgres | Fargate tasks (2 by default) behind an ALB, with the database on RDS |
| Redundancy | None (one server) | Spread across multiple AZs. RDS is Multi-AZ by default |
| Database backups | Daily EBS snapshots (14 kept by default) plus a daily pg_dump | RDS automated backups (7 days by default) |
| Operational effort | Medium (updates are done on the instance) | Medium to high (you manage Terraform) |
| Rough cost | About $25 per month | About $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.microRDS 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.comandtraypage-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
- Create the CloudFormation stack, specifying
AppDomain,ViewerDomain, and the email settings. You can use the AWS console or the CLI. - When the stack completes, point A records for both host names at the
ElasticIpoutput. - Confirm that
https://<app host>/healthresponds 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_dumpis also saved to a backup S3 bucket. Page content is protected by S3 versioning. - Updates: changing
TraypageImageand 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 rundocker compose pullandup -d. Pin the tag to a version such asv1.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
- Prepare the prerequisites: Terraform 1.5 or later, AWS credentials, two host names, DNS and a certificate, and an SES sender domain.
- Create a variables file from the bundled example (
examples/standard). The required variables areimage_tag,app_hostname,viewer_hostname, andemail_from. If you use Route 53, also setroute53_zone_id. - Run
terraform init,terraform plan, andterraform 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. - 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_tagto the new version and runterraform 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.
Related settings
- For settings such as the header used to read the client IP (
TRUSTED_CLIENT_IP_HEADER), email, and storage, see the Configuration reference. In the minimal setup, the template setsTRUSTED_CLIENT_IP_HEADERfor you. - For license keys, see License and Enterprise features.
- To compare setups, see Self-hosting.
- Instead of the AWS templates, you can run the Docker / VPS setup on a single server, optionally using RDS for Postgres and S3 for page content (Using external Postgres and S3).