Short answer
To host Odoo on AWS, run it on an EC2 instance with a gp3 EBS disk. Put Postgres on the same instance or on Amazon RDS. Open only ports 80 and 443, and log in with Systems Manager instead of SSH. Send backups of the database and the files to an S3 bucket that the instance can write to but not read or delete. Skip burstable instances for live use, and test a restore before you go live.
Many companies already have an AWS account, so Odoo goes there too. AWS has a region close to almost anyone. It also has many ways to get Odoo slightly wrong. This guide gives you the exact commands for each AWS step.
For the install itself (Odoo, Postgres, the config file and the proxy), follow our guide on running Odoo on a VPS. This page covers what's different on AWS.
You can run every aws command below from your own computer with the AWS CLI, or from AWS CloudShell in the console. Replace the IDs in capitals with your own.
Which EC2 instance to pick
Two things matter most for Odoo:
- Memory. The minimum to start is 2 vCPU, 4 GB of memory and a 50 GB disk. That runs Odoo and Postgres on one instance for a small team. Each worker needs its share of memory, and Postgres wants its own. For more users, big imports or RDS, our guide on how many Odoo workers you need shows how to size it.
- Steady CPU. Odoo is busy all day while people work in it.
Why not a T instance
T instances (for example t3 or t4g) are "burstable." They earn CPU credits when quiet and spend them when busy. New T3, T3a and T4g instances start in "unlimited" mode. In that mode the instance never slows down, but if its average CPU stays above a set baseline over 24 hours, AWS bills the extra. AWS's own math says a T3 running flat out costs more than the same size M5. Odoo used all day often lives above that baseline.
So for production, pick a fixed performance family:
- M family (general purpose), for example m7i.large or m7g.large. A good default.
- R family (more memory per CPU), for example r7i.large. Good when Postgres runs on the same instance and the database is large.
T instances are fine for a test copy that few people use.
Graviton (ARM) is fine, if PDFs print
Instances with a "g" in the name, such as m7g, use AWS Graviton chips (arm64). Odoo and Postgres run well on them. The one part to check is wkhtmltopdf, which Odoo uses to print PDFs. Its official packages for arm64 exist for Ubuntu 22.04 (jammy) and Debian 12 (bookworm). Pick one of those, install the matching arm64 package, and print an invoice before you go live. If your operating system is not on that list, use an Intel or AMD instance (for example m7i or m7a). If you may hand the Odoo work to us later, pick Intel or AMD too: our bring your own server software runs on x86 servers only.
Disk: gp3 EBS
Keep all data on EBS, not on instance store disks. Instance store is wiped when the instance stops.
Use the gp3 volume type. It gives a steady 3,000 IOPS and 125 MiB/s at any size, and you can raise both later without a bigger disk. Leave room for the filestore and three days of local backups.
Region
Pick the region with care. Your data lives in the region you choose. Put the instance, the database and the backup bucket in the same region, unless you want backups in a second region on purpose.
Where Postgres goes
You have two good options:
- On the same EC2 instance. Simple, and fine for many teams. Follow the VPS guide as written.
- On Amazon RDS for PostgreSQL. AWS handles the database server, patches and point-in-time recovery. The steps are below.
Set up RDS for Odoo
RDS needs a "DB subnet group": at least two subnets in two different Availability Zones, in the same VPC as your EC2 instance. Find your subnets, then create the group:
aws ec2 describe-subnets --filters Name=vpc-id,Values=VPC_ID \
--query 'Subnets[].[SubnetId,AvailabilityZone]' --output table
aws rds create-db-subnet-group \
--db-subnet-group-name odoo-db-subnets \
--db-subnet-group-description "Odoo database" \
--subnet-ids '["SUBNET_A","SUBNET_B"]'
The database also needs its own security group. Create it now with the commands in the next section (odoo-db). Then create the database:
aws rds create-db-instance \
--db-instance-identifier odoo-db \
--engine postgres \
--db-instance-class db.m7g.large \
--storage-type gp3 \
--allocated-storage 50 \
--master-username odoo_admin \
--manage-master-user-password \
--db-subnet-group-name odoo-db-subnets \
--vpc-security-group-ids SG_ODOO_DB \
--no-publicly-accessible \
--backup-retention-period 14 \
--storage-encrypted \
--deletion-protection
What these do:
--manage-master-user-passwordlets RDS make the admin password and keep it in Secrets Manager. It never sits in your shell history.--no-publicly-accessiblekeeps the database off the internet.--backup-retention-period 14keeps 14 days of RDS backups. From the CLI, the default is only 1 day.--deletion-protectionstops anyone from deleting the database by mistake.- Add
--multi-azif you want a standby copy in a second zone. - Add
--engine-versionto pick a Postgres version.aws rds describe-db-engine-versions --engine postgreslists them.
Create the odoo database user
Odoo needs its own user that can create databases. Don't use the RDS admin user for daily work.
Get the admin password and the database address:
SECRET_ARN=$(aws rds describe-db-instances --db-instance-identifier odoo-db \
--query 'DBInstances[0].MasterUserSecret.SecretArn' --output text)
aws secretsmanager get-secret-value --secret-id "$SECRET_ARN" \
--query SecretString --output text
aws rds describe-db-instances --db-instance-identifier odoo-db \
--query 'DBInstances[0].Endpoint.Address' --output text
On the Odoo instance, connect with psql and create the user:
psql "host=YOUR_RDS_ENDPOINT user=odoo_admin dbname=postgres sslmode=require"
CREATE ROLE odoo WITH LOGIN CREATEDB PASSWORD 'a-long-random-password';
The RDS admin user is not a full Postgres superuser, but it can create roles like this one.
Point Odoo at RDS
In odoo.conf:
db_host = YOUR_RDS_ENDPOINT
db_port = 5432
db_user = odoo
db_password = a-long-random-password
db_sslmode = require
On Postgres 15 and later, RDS turns on rds.force_ssl by default. It refuses connections without SSL. db_sslmode = require makes Odoo always use it.
Match the Postgres client version
pg_dump will not dump a server with a newer major version than itself. If RDS runs Postgres 17, the Odoo instance needs the version 17 client. Install it from the PostgreSQL apt repository:
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-client-17
RDS backups don't cover the filestore
RDS backs up the database and can restore it to any second in the retention window. It does not back up the filestore. Attachments and images live on the EC2 disk, under /var/lib/odoo/filestore. You still need the S3 backup below, so the database and the files are saved together.
Network and security groups
Security groups
Create one group for the Odoo instance and one for the database:
VPC_ID=$(aws ec2 describe-vpcs --filters Name=isDefault,Values=true \
--query 'Vpcs[0].VpcId' --output text)
aws ec2 create-security-group --group-name odoo-web \
--description "Odoo web" --vpc-id "$VPC_ID"
aws ec2 authorize-security-group-ingress --group-id SG_ODOO_WEB \
--protocol tcp --port 80 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress --group-id SG_ODOO_WEB \
--protocol tcp --port 443 --cidr 0.0.0.0/0
There is no rule for port 22. You will log in with Systems Manager instead.
If you use RDS, let only the Odoo instance reach Postgres. The rule points at the odoo-web group, not at an IP address:
aws ec2 create-security-group --group-name odoo-db \
--description "Odoo database" --vpc-id "$VPC_ID"
aws ec2 authorize-security-group-ingress --group-id SG_ODOO_DB \
--protocol tcp --port 5432 --source-group SG_ODOO_WEB
Odoo's own ports (8069 and 8072) stay closed. Only nginx on the instance talks to them.
An IAM role for the instance
The instance needs an identity in AWS. It lets it use Systems Manager, send disk metrics, and later write backups. Save this as ec2-trust.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
Then create the role and its instance profile:
aws iam create-role --role-name odoo-server \
--assume-role-policy-document file://ec2-trust.json
aws iam attach-role-policy --role-name odoo-server \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
aws iam attach-role-policy --role-name odoo-server \
--policy-arn arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy
aws iam create-instance-profile --instance-profile-name odoo-server
aws iam add-role-to-instance-profile \
--instance-profile-name odoo-server --role-name odoo-server
An EC2 instance doesn't take a role directly. It takes an "instance profile" that holds the role. If you make the role in the IAM console instead, the console makes the profile for you, with the same name.
Launch the instance
In the EC2 console, go to Instances > Launch instances and set:
- AMI: Ubuntu Server 22.04 LTS or Debian 12 (see the Graviton note above)
- Instance type: for example m7i.large or m7g.large
- Key pair: none needed, since you won't use SSH
- Network settings: your VPC, a public subnet, and the existing group
odoo-web - Storage: gp3, sized for the filestore plus backups
- Advanced details > IAM instance profile:
odoo-server
If the instance already runs, attach the profile with:
aws ec2 associate-iam-instance-profile --instance-id INSTANCE_ID \
--iam-instance-profile Name=odoo-server
A fixed address
Give the instance an Elastic IP, so its address doesn't change when it stops and starts:
aws ec2 allocate-address --domain vpc
aws ec2 associate-address --instance-id INSTANCE_ID --allocation-id EIPALLOC_ID
Point your domain's DNS record at that address before you ask Let's Encrypt for a certificate.
Log in with Session Manager, not SSH
AWS Systems Manager Session Manager gives you a shell with no open port and no SSH key. Ubuntu Server AMIs come with the SSM Agent. The AmazonSSMManagedInstanceCore policy on the role does the rest. The instance only needs outbound HTTPS, which security groups allow by default.
To connect, open the instance in the EC2 console and choose Connect > Session Manager. Or, from your computer, install the Session Manager plugin for the AWS CLI and run:
aws ssm start-session --target INSTANCE_ID
You land as ssm-user, which can use sudo.
Backups to S3
Create the bucket
aws s3api create-bucket --bucket your-odoo-backups --region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1
In us-east-1, leave out the --create-bucket-configuration line. Bucket names are shared by all AWS customers, so add something unique to yours.
Block public access. New buckets already block it, and this makes it explicit:
aws s3api put-public-access-block --bucket your-odoo-backups \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
Turn on versioning. If a backup file is ever overwritten or deleted, the old copy stays as an older version:
aws s3api put-bucket-versioning --bucket your-odoo-backups \
--versioning-configuration Status=Enabled
Remove old backups on a schedule
Save this as lifecycle.json:
{
"Rules": [
{
"ID": "expire-old-odoo-backups",
"Filter": { "Prefix": "" },
"Status": "Enabled",
"Expiration": { "Days": 30 },
"NoncurrentVersionExpiration": { "NoncurrentDays": 30 },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
}
]
}
aws s3api put-bucket-lifecycle-configuration --bucket your-odoo-backups \
--lifecycle-configuration file://lifecycle.json
Backups older than 30 days are removed. With versioning on, "removed" first means "hidden." The hidden copy stays 30 more days, then goes for good. The last line cleans up uploads that broke halfway. This command replaces any lifecycle rules the bucket had before.
Let the instance write, and only write
This is the policy for the role. It allows one action, s3:PutObject, on one bucket. Save it as backup-write.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "WriteOdooBackupsOnly",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::your-odoo-backups/*"
}
]
}
aws iam put-role-policy --role-name odoo-server \
--policy-name odoo-backup-write --policy-document file://backup-write.json
s3:PutObject also covers large files, which the CLI sends in parts. The instance can't list, read or delete anything in the bucket. If someone breaks into the server, they can't wipe your backups. With versioning, even an overwrite leaves the old copy in place.
If your rules need more, S3 Object Lock can block deletes for a set number of days, even by an admin. Once you turn it on for a bucket, you can't turn it off. Test it on a spare bucket first.
Install the AWS CLI on the instance
sudo apt install -y unzip
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install
On Graviton, use awscli-exe-linux-aarch64.zip instead. The CLI picks up the role's credentials by itself. You never store an access key on the server.
The backup script
Create the local folder first:
sudo mkdir -p /backups
sudo chown odoo:odoo /backups
Save this as /usr/local/bin/odoo-backup.sh and run sudo chmod 755 on it:
#!/bin/bash
# /usr/local/bin/odoo-backup.sh, run as the odoo user
set -euo pipefail
DB=yourdb
DAY=$(date +%F)
DIR=/backups/$DAY
mkdir -p "$DIR"
pg_dump -Fc "$DB" > "$DIR/$DB.dump"
tar -czf "$DIR/$DB-filestore.tar.gz" -C /var/lib/odoo/filestore "$DB"
aws s3 cp --recursive --only-show-errors "$DIR" "s3://your-odoo-backups/$DAY/"
find /backups -mindepth 1 -maxdepth 1 -type d -mtime +3 -exec rm -rf {} +
With RDS, pg_dump must connect over the network. Change the pg_dump line to:
pg_dump -h YOUR_RDS_ENDPOINT -U odoo -Fc "$DB" > "$DIR/$DB.dump"
Then give the odoo user its password in a .pgpass file, in its home folder:
sudo -u odoo -H bash -c 'echo "YOUR_RDS_ENDPOINT:5432:*:odoo:a-long-random-password" > ~/.pgpass && chmod 600 ~/.pgpass'
The * lets the same line work for every database, including the restore test.
Run it every night
Create /etc/systemd/system/odoo-backup.service:
[Unit]
Description=Odoo backup to S3
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=odoo
ExecStart=/usr/local/bin/odoo-backup.sh
And /etc/systemd/system/odoo-backup.timer:
[Unit]
Description=Run the Odoo backup every night
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
Turn it on, then run it once by hand and check the result:
sudo systemctl daemon-reload
sudo systemctl enable --now odoo-backup.timer
sudo systemctl start odoo-backup.service
journalctl -u odoo-backup.service -n 20
From your own computer, check that the files arrived:
aws s3 ls s3://your-odoo-backups/ --recursive
Test a restore
The production instance can't read the bucket. That's on purpose. So the restore test runs on a second instance with its own read-only role. Install Odoo on it with the VPS guide, then save this as backup-read.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListOdooBackups",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::your-odoo-backups"
},
{
"Sid": "ReadOdooBackups",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-odoo-backups/*"
}
]
}
Create the role the same way as before, with the same ec2-trust.json:
aws iam create-role --role-name odoo-restore \
--assume-role-policy-document file://ec2-trust.json
aws iam attach-role-policy --role-name odoo-restore \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
aws iam put-role-policy --role-name odoo-restore \
--policy-name odoo-backup-read --policy-document file://backup-read.json
aws iam create-instance-profile --instance-profile-name odoo-restore
aws iam add-role-to-instance-profile \
--instance-profile-name odoo-restore --role-name odoo-restore
aws ec2 associate-iam-instance-profile --instance-id TEST_INSTANCE_ID \
--iam-instance-profile Name=odoo-restore
On the test instance, as the odoo user, pull one night's backup and restore it:
mkdir -p /tmp/restore && cd /tmp/restore
aws s3 cp --recursive s3://your-odoo-backups/2026-10-25/ .
createdb -O odoo yourdb_restored
pg_restore -d yourdb_restored --no-owner --role=odoo yourdb.dump
mkdir -p /var/lib/odoo/filestore/yourdb_restored
tar -xzf yourdb-filestore.tar.gz -C /var/lib/odoo/filestore/yourdb_restored --strip-components=1
If the test copy uses RDS, add -h YOUR_RDS_ENDPOINT -U odoo to createdb and pg_restore.
Before you start Odoo on the copy, neutralize it. A copy of production still has your mail servers and scheduled jobs, so it can email real customers. As the odoo user, run:
odoo neutralize -c /etc/odoo/odoo.conf -d yourdb_restored
Use odoo-bin instead of odoo if you run Odoo from source. This turns off outgoing mail servers, scheduled actions and payment providers in the copy. Our guide on copying production to staging explains what it covers and what it misses.
Then log in to yourdb_restored and open a few attachments. Our guide on how to know if an Odoo backup will restore covers the full test.
Get an alert before the disk fills up
Logs, the filestore and local backups all grow. AWS does not see how full your disk is unless you send it that number. The CloudWatch agent does this.
Install it on the instance (use arm64 in the address on Graviton):
curl -O https://amazoncloudwatch-agent.s3.amazonaws.com/ubuntu/amd64/latest/amazon-cloudwatch-agent.deb
sudo dpkg -i -E ./amazon-cloudwatch-agent.deb
Save this as /opt/aws/amazon-cloudwatch-agent/etc/odoo-disk.json:
{
"metrics": {
"append_dimensions": {
"InstanceId": "${aws:InstanceId}"
},
"metrics_collected": {
"disk": {
"measurement": ["used_percent"],
"resources": ["/"],
"drop_device": true,
"metrics_collection_interval": 60
}
}
}
}
Start the agent with it:
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
-a fetch-config -m ec2 -s \
-c file:/opt/aws/amazon-cloudwatch-agent/etc/odoo-disk.json
The metric shows up in CloudWatch as disk_used_percent, in the CWAgent namespace. An alarm only fires if it names the exact same dimensions. So list them first, from your computer:
aws cloudwatch list-metrics --namespace CWAgent \
--metric-name disk_used_percent \
--dimensions Name=InstanceId,Value=INSTANCE_ID
Create an email topic, and confirm the email AWS sends you:
aws sns create-topic --name odoo-alerts
aws sns subscribe --topic-arn TOPIC_ARN \
--protocol email --notification-endpoint [email protected]
Then create the alarm. Copy the dimensions from the list-metrics output. On Ubuntu, they are usually these:
aws cloudwatch put-metric-alarm \
--alarm-name odoo-disk-80 \
--namespace CWAgent --metric-name disk_used_percent \
--dimensions Name=InstanceId,Value=INSTANCE_ID Name=path,Value=/ Name=fstype,Value=ext4 \
--statistic Average --period 300 --evaluation-periods 1 \
--threshold 80 --comparison-operator GreaterThanOrEqualToThreshold \
--treat-missing-data breaching \
--alarm-actions TOPIC_ARN
--treat-missing-data breaching also warns you if the agent stops sending.
Outgoing email
By default, EC2 throttles mail sent on port 25. Odoo mail sent that way will time out or vanish. Send through an SMTP service on port 587 instead. Your current mail provider works. So does Amazon SES:
- In the SES console, verify your domain. SES gives you DNS records to add.
- Go to SMTP settings > Create SMTP credentials. The SMTP user and password are not your AWS keys, and they only work in one region.
- New SES accounts can only send to addresses you have verified, up to 200 mails a day. Request production access from the SES Account dashboard. AWS answers within about 4 days.
- In Odoo, turn on developer mode. Go to Settings > Technical > Outgoing Mail Servers. Set the server to
email-smtp.YOUR_REGION.amazonaws.com, port 587, encryption TLS (STARTTLS), and the SMTP user and password.
Only ask AWS to lift the port 25 limit if you must send on port 25.
Odoo traps on any server, AWS included
Websocket. Since Odoo 16, chat and live updates use port 8072. nginx must send /websocket there, with the upgrade headers.
Proxy mode. Odoo only trusts forwarded headers when proxy_mode = True and the request has X-Forwarded-Host. Without both, your links come out on http://.
Database manager. Set list_db = False and a strong admin_passwd.
Workers. Set workers above 0, sized to memory. Then Odoo starts the chat worker on gevent_port.
In odoo.conf:
proxy_mode = True
list_db = False
admin_passwd = a-long-random-string
workers = 4
gevent_port = 8072
In nginx, next to your location / block:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location /websocket {
proxy_pass http://127.0.0.1:8072;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}
The map block goes in the http section, outside server.
Behind an Application Load Balancer
You don't need a load balancer for one instance. If you use one anyway:
- The ALB handles websockets on its own. Keep nginx behind it, so
/websocketstill goes to 8072. - The ALB sends
X-Forwarded-ProtoandX-Forwarded-For, but notX-Forwarded-Host. Odoo ignores the others without it. So nginx must add it. - The ALB ends HTTPS, so nginx sees plain HTTP. Pass on the ALB's protocol, not nginx's own.
In both nginx location blocks, use:
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
Then open port 80 on odoo-web to the ALB's security group only, not to the whole internet.
Before you go live
- The instance is an M or R type (not T), with a gp3 disk
- Odoo runs with
workersabove 0, sized to the server's memory /websocketgoes to port 8072, and chat worksproxy_mode = True, and links in emails start withhttps://list_db = Falseand a strongadmin_passwd- Only ports 80 and 443 are open, and you log in with Session Manager
- With RDS:
db_sslmode = require, and onlyodoo-webcan reach port 5432 - The SSL certificate renews on its own
- PDFs print (wkhtmltopdf works, on Graviton too)
- Test emails arrive, sent on port 587
- Logs rotate, and the disk alarm email is confirmed
- The backup timer ran, and the files are in S3
- A backup has been restored on another instance, and the attachments open
Keep AWS, hand over the Odoo work
You can keep Odoo in your AWS account and still not run it yourself. With bring your own server, the instance stays in your account and your chosen region. We run Odoo on it: deploys from Git, staging, backups, updates and help from Odoo experts. You keep the instance running and big enough. Our software connects out from your instance, so you don't open any port to us.
Skysize is built by ex-Odoo engineers and is ISO 27001 certified.
Talk to an Odoo hosting expert
FAQ
Can Odoo run on AWS? Yes. Use an EC2 instance with an EBS disk, Postgres on the instance or on RDS, and backups to S3.
Can I use Amazon RDS for Odoo? Yes. Create a database user for Odoo that can create databases. RDS doesn't back up the filestore, so back that up too.
Which EC2 instance is best for Odoo? One with enough memory for your workers and Postgres, and a steady CPU. Avoid burstable instances for production use.
Can Odoo run on Graviton (ARM) instances? Yes. Check that wkhtmltopdf has an arm64 package for your operating system. It does for Ubuntu 22.04 and Debian 12. Then print a PDF before you go live. For our bring your own server, pick Intel or AMD instead, since it runs on x86 only.
What IAM permissions does the Odoo server need for backups?
Only s3:PutObject on your backup bucket. Restores use a separate role, on another instance, with s3:ListBucket and s3:GetObject.
Do I need to open port 22? No. Session Manager gives you a shell through the SSM Agent, with no inbound port.
Why doesn't Odoo send email from EC2? EC2 throttles port 25 by default. Send through SES or your mail provider on port 587.