Self-hosting

Deploy on AWS

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

60 minutes · Advanced

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.

1. Create two security groups

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

NameInbound rules
oatmilk-webSSH (22) from your own IP address; HTTP (80) and HTTPS (443) from anywhere
oatmilk-dataPostgreSQL (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:

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

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:

Text
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:

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:

Shell
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
QuestionAnswer
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:

Shell
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

Shell
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

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