# Deploy on AWS

> Run Oatmilk on EC2 with Amazon RDS for PostgreSQL, ElastiCache and S3, step by step.

Source: https://app.getoatmilk.com/docs/self-hosting/aws

This guide runs Oatmilk on one EC2 instance, with its data in AWS's managed services: Amazon RDS for PostgreSQL, ElastiCache for Redis or Valkey, and S3 for files. Do every step in the same region, for example `ca-central-1`.

> [!NOTE]
> Oatmilk's own tests run the bundled stack in Docker. The managed services here speak the same protocols (PostgreSQL, Redis and S3), but check each step's result as you go, and run `bun run self-host doctor` at the end.

## 1. Create two security groups

In **EC2 › Security Groups**, in your VPC (the default VPC is fine):

| Name | Inbound rules |
| --- | --- |
| `oatmilk-web` | SSH (22) from your own IP address; HTTP (80) and HTTPS (443) from anywhere |
| `oatmilk-data` | PostgreSQL (5432) and custom TCP 6379, both from the `oatmilk-web` security group |

The database and Redis only accept connections from the instance.

## 2. Create the database

In **RDS › Create database**:

1. **Standard create**, engine **PostgreSQL**, version 17.
2. **Templates:** Production, or Dev/Test for a trial.
3. **Master username:** `postgres`. Set a strong password and keep it.
4. **Instance:** `db.t4g.medium` or larger, with 20 GB of storage or more.
5. **Connectivity:** your VPC, **Public access: No**, security group `oatmilk-data`.
6. **Additional configuration › Initial database name:** `oatmilk`. Keep automated backups and encryption on.

When it is **Available**, copy its **Endpoint**. The connection string is:

```
postgres://postgres:<password>@<endpoint>:5432/oatmilk?sslmode=require
```

> [!WARNING]
> Encode special characters in the password for a URL: `@` becomes `%40`, `:` becomes `%3A`, `/` becomes `%2F`. Or choose a password of letters and digits.

## 3. Create Redis

In **ElastiCache › Create cache**:

1. Choose **Valkey** or **Redis OSS**, then **Design your own cache** and **Node-based cluster**.
2. **Cluster mode: Disabled**, node type `cache.t4g.small`, one replica or none.
3. Your VPC's subnets, security group `oatmilk-data`.
4. **Encryption in transit: on.** For access control, set an **AUTH token**.

When it is **Available**, copy the **Primary endpoint**. The address is:

```
rediss://:<auth token>@<primary endpoint>:6379
```

## 4. Create the bucket for files

In **S3 › Create bucket**, name it, for example `acme-oatmilk-files`, in your region. Keep **Block all public access** on.

In **IAM › Users**, create `oatmilk-storage` with no console access, and add this inline policy:

```json title="oatmilk-storage policy"
{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Action": ["s3:ListBucket", "s3:GetBucketLocation"], "Resource": "arn:aws:s3:::acme-oatmilk-files" },
    { "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"], "Resource": "arn:aws:s3:::acme-oatmilk-files/*" }
  ]
}
```

Under the user's **Security credentials**, create an access key for **Application running outside AWS**, and copy the key ID and secret.

Browsers never reach the bucket: Oatmilk hands out short-lived signed links on your own domain, so the bucket needs no public access and no CORS rules.

## 5. Launch the instance

In **EC2 › Launch instance**:

1. **Ubuntu Server 24.04 LTS**, instance type `t3.large` (2 CPUs, 8 GB) or larger.
2. A key pair for SSH, your VPC, a public subnet, security group `oatmilk-web`.
3. 40 GB of `gp3` storage.

Then, in **Elastic IPs**, allocate an address and associate it with the instance, so its address never changes.

## 6. Point your domain at it

In **Route 53 › Hosted zones**, or at your DNS provider, add an `A` record for `books.example.com` with the Elastic IP. Wait until `dig +short books.example.com` answers with it.

## 7. Install Oatmilk on the instance

SSH in, then:

```bash
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker ubuntu && newgrp docker
curl -fsSL https://bun.sh/install | bash && source ~/.bashrc
git clone https://github.com/AGI-Ventures-Canada/oatmilk.git && cd oatmilk
bun install
bun run self-host setup
```

| Question | Answer |
| --- | --- |
| Where will Oatmilk run? | **On a server**, at `books.example.com` |
| Which PostgreSQL database? | **Use one I already have**, then the RDS connection string |
| Which Redis? | **Use one I already have**, then the ElastiCache address |
| Where should files be kept? | **In an S3-compatible bucket**: the bucket name, your region, an empty endpoint, then the access key ID and secret |
| How should people sign in? | **Accounts kept here**, with sign-up **Only people I add or invite** |
| Which AI models…? | **Vercel AI Gateway**, or a model server in your VPC as **Another OpenAI-compatible server** |
| Should Oatmilk send email? | **Yes, with Resend** |

Or, without questions:

```bash
export OATMILK_S3_ACCESS_KEY_ID=AKIA…
export OATMILK_S3_SECRET_ACCESS_KEY=…
bun run self-host init --server --domain books.example.com \
  --database-url 'postgres://postgres:…@<endpoint>:5432/oatmilk?sslmode=require' \
  --redis-url 'rediss://:…@<primary endpoint>:6379' \
  --s3-bucket acme-oatmilk-files --s3-region ca-central-1
bun run self-host up
```

On the first start, Oatmilk prepares the database (its roles and schemas) and applies every migration. The first build takes about 10 minutes.

## 8. Check it and sign in

```bash
bun run self-host doctor
bun run self-host logs db-init migrate   # if preparing the database failed
```

Open `https://books.example.com`, sign in with the account setup made, and create your company. Upload a receipt to check that files reach S3.

## Back up

- **Database:** RDS automated backups and snapshots. `bun run self-host backup` doesn't copy a database it doesn't run.
- **Files:** turn on **Versioning** on the bucket, or replicate it to another region.
- **Settings:** keep a copy of `self-host/.env` somewhere safe. It holds the keys that decrypt connector credentials and contractor details; without it, a restored database can't read them.

## If something goes wrong

| Problem | Fix |
| --- | --- |
| The database steps can't connect | Check that `oatmilk-data` allows 5432 from `oatmilk-web`, and that the string ends in `?sslmode=require`. |
| `db-init` says permission denied creating a role | Use the RDS master user. Oatmilk creates its own roles on the first start. |
| The app can't reach Redis | Check the `rediss://` scheme, port 6379, the AUTH token, and that cluster mode is disabled. |
| Uploads fail | Check the bucket's region in `self-host/.env` and the IAM policy's bucket name. |
