odoo.
odoo6 min read

Cài đặt Odoo CE 19 trên Ubuntu bằng Docker Compose

Hướng dẫn từng bước cài Odoo CE 19 production-ready trên Ubuntu 22.04/24.04 với docker-compose, PostgreSQL 16, nginx reverse proxy và SSL Let's Encrypt.

C\u00e0i \u0111\u1eb7t Odoo CE 19 tr\u00ean Ubuntu b\u1eb1ng Docker Compose

Odoo CE 19 ra m\u1eaft v\u1edbi nhi\u1ec1u c\u1ea3i ti\u1ebfn v\u1ec1 hi\u1ec7u n\u0103ng ORM v\u00e0 web client. B\u00e0i vi\u1ebft n\u00e0y h\u01b0\u1edbng d\u1eabn d\u1ef1ng m\u1ed9t stack production-ready tr\u00ean m\u1ed9t VPS Ubuntu duy nh\u1ea5t, d\u00f9ng docker-compose \u0111\u1ec3 qu\u1ea3n l\u00fd lifecycle, PostgreSQL 16 cho database, v\u00e0 nginx l\u00e0m reverse proxy v\u1edbi SSL t\u1eeb Let's Encrypt. To\u00e0n b\u1ed9 stack ch\u1ea1y \u0111\u01b0\u1ee3c tr\u00ean VPS 2 vCPU / 4GB RAM ph\u1ee5c v\u1ee5 20-30 user concurrent \u2014 \u0111\u1ee7 cho ph\u1ea7n l\u1edbn SME Vi\u1ec7t Nam \u1edf giai \u0111o\u1ea1n pilot.

V\u00ec sao ch\u1ecdn docker-compose thay v\u00ec c\u00e0i native b\u1eb1ng apt?

C\u00e0i Odoo native qua package .deb t\u1eeb nightly.odoo.com v\u1eabn ch\u1ea1y t\u1ed1t, nh\u01b0ng c\u00f3 ba \u0111i\u1ec3m y\u1ebfu khi v\u1eadn h\u00e0nh d\u00e0i h\u1ea1n:

  1. Upgrade kh\u00f3 rollback: native install ghi \u0111\u00e8 binary tr\u1ef1c ti\u1ebfp. Khi apt upgrade h\u1ecfng (v\u00ed d\u1ee5 Python lib version mismatch), kh\u00f4i ph\u1ee5c m\u1ea5t 1-2 gi\u1edd.
  2. Ph\u1ee5 thu\u1ed9c Python system: Odoo 19 y\u00eau c\u1ea7u Python 3.11+. N\u1ebfu Ubuntu 22.04 default 3.10, ph\u1ea3i d\u00f9ng PPA deadsnakes \u2014 g\u00e2y xung \u0111\u1ed9t v\u1edbi c\u00e1c app kh\u00e1c c\u00f9ng m\u00e1y.
  3. Multi-instance ph\u1ee9c t\u1ea1p: ch\u1ea1y 2 instance Odoo (test + prod) tr\u00ean c\u00f9ng m\u00e1y v\u1edbi native c\u1ea7n manually qu\u1ea3n l\u00fd systemd unit + port + workers config.

Docker gi\u1ea3i quy\u1ebft c\u1ea3 ba: image official odoo:19 t\u1eeb Docker Hub \u0111\u00e3 \u0111\u00f3ng g\u00f3i \u0111\u00fang Python runtime, rollback ch\u1ec9 c\u1ea7n \u0111\u1ed5i tag image, v\u00e0 m\u1ed7i instance l\u00e0 m\u1ed9t compose stack \u0111\u1ed9c l\u1eadp. Trade-off: docker th\u00eam ~150MB overhead RAM, v\u00e0 debug \u0111\u00f4i l\u00fac c\u1ea7n docker exec thay v\u00ec tail tr\u1ef1c ti\u1ebfp file log.

Chu\u1ea9n b\u1ecb server

M\u1eb7c \u0111\u1ecbnh b\u1ea1n \u0111\u00e3 c\u00f3 VPS Ubuntu 22.04 ho\u1eb7c 24.04 v\u1edbi user non-root c\u00f3 quy\u1ec1n sudo. C\u00e0i Docker engine theo h\u01b0\u1edbng d\u1eabn ch\u00ednh th\u1ee9c:

# C\u00e0i docker + compose plugin
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker $USER
# Logout + login l\u1ea1i \u0111\u1ec3 group c\u00f3 hi\u1ec7u l\u1ef1c

Verify:

docker --version          # 24.x tr\u1edf l\u00ean
docker compose version    # v2.20+

C\u1ea5u tr\u00fac th\u01b0 m\u1ee5c

T\u1ea1o project directory t\u00e1ch bi\u1ec7t cho t\u1eebng instance:

mkdir -p ~/odoo-prod/{config,addons,postgres-data,odoo-data}
cd ~/odoo-prod

Layout sau khi xong:

odoo-prod/
\u251c\u2500\u2500 docker-compose.yml
\u251c\u2500\u2500 .env
\u251c\u2500\u2500 config/
\u2502   \u2514\u2500\u2500 odoo.conf
\u251c\u2500\u2500 addons/                  # custom modules mount v\u00e0o container
\u251c\u2500\u2500 postgres-data/           # persistent volume cho postgres
\u2514\u2500\u2500 odoo-data/               # filestore + sessions

File .env \u2014 qu\u1ea3n l\u00fd secrets

T\u00e1ch credential ra file ri\u00eang, kh\u00f4ng commit v\u00e0o git:

# .env
POSTGRES_USER=odoo
POSTGRES_PASSWORD=ChangeMe_StrongPassword_2026
POSTGRES_DB=postgres
ODOO_ADMIN_PASSWD=AdminMasterPassword_NeverShare
DOMAIN=odoo.your-site.example.com

ODOO_ADMIN_PASSWD l\u00e0 master password \u0111\u1ec3 t\u1ea1o/restore database t\u1eeb web UI /web/database/manager. \u0110\u1eb7t m\u1eadt kh\u1ea9u m\u1ea1nh v\u00e0 kh\u00f4ng bao gi\u1edd chia s\u1ebb \u2014 ai c\u00f3 n\u00f3 \u0111\u1ec1u c\u00f3 th\u1ec3 drop database c\u1ee7a b\u1ea1n.

File config/odoo.conf

[options]
addons_path = /mnt/extra-addons
data_dir = /var/lib/odoo
admin_passwd = $ODOO_ADMIN_PASSWD
db_host = db
db_port = 5432
db_user = odoo
db_password = $POSTGRES_PASSWORD
db_maxconn = 64
proxy_mode = True
workers = 3
max_cron_threads = 1
limit_memory_hard = 2684354560
limit_memory_soft = 2147483648
limit_request = 8192
limit_time_cpu = 600
limit_time_real = 1200
log_level = info

Hai option quan tr\u1ecdng:

  • proxy_mode = True \u2014 b\u00e1o Odoo r\u1eb1ng n\u00f3 \u0111\u1ee9ng sau reverse proxy (nginx). Kh\u00f4ng b\u1eadt flag n\u00e0y th\u00ec t\u1ea5t c\u1ea3 URL Odoo generate s\u1ebd l\u00e0 http:// thay v\u00ec https://, d\u1eabn \u0111\u1ebfn mixed-content errors tr\u00ean browser.
  • workers = 3 \u2014 multi-process mode. C\u00f4ng th\u1ee9c quen d\u00f9ng: workers = (CPU * 2) + 1. Tr\u00ean VPS 2 vCPU \u0111\u1eb7t 3-5 l\u00e0 v\u1eeba. \u0110\u1eb7t workers = 0 ch\u1ea1y thread mode, ch\u1ec9 ph\u00f9 h\u1ee3p dev.

File docker-compose.yml

services:
  db:
    image: postgres:16-alpine
    container_name: odoo-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - ./postgres-data:/var/lib/postgresql/data
    networks:
      - odoo-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5

  odoo:
    image: odoo:19
    container_name: odoo-app
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:8069:8069"
      - "127.0.0.1:8072:8072"
    environment:
      HOST: db
      USER: ${POSTGRES_USER}
      PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./config:/etc/odoo
      - ./addons:/mnt/extra-addons
      - ./odoo-data:/var/lib/odoo
    networks:
      - odoo-net

networks:
  odoo-net:
    driver: bridge

L\u01b0u \u00fd port 8069 (HTTP) v\u00e0 8072 (longpolling/chat) ch\u1ec9 bind v\u00e0o 127.0.0.1. Public traffic \u0111i qua nginx \u2014 kh\u00f4ng bao gi\u1edd expose Odoo tr\u1ef1c ti\u1ebfp ra internet.

Kh\u1edfi \u0111\u1ed9ng stack:

docker compose up -d
docker compose logs -f odoo    # theo d\u00f5i \u0111\u1ebfn khi th\u1ea5y "HTTP service running"

Truy c\u1eadp t\u1ea1m http://your-vps-ip:8069 \u0111\u1ec3 check (s\u1ebd ch\u1eb7n sau khi g\u1eafn nginx).

Reverse proxy nginx + SSL Let's Encrypt

C\u00e0i nginx + certbot:

sudo apt-get install -y nginx certbot python3-certbot-nginx

T\u1ea1o /etc/nginx/sites-available/odoo:

upstream odoo_http {
    server 127.0.0.1:8069;
}

upstream odoo_longpolling {
    server 127.0.0.1:8072;
}

server {
    listen 80;
    server_name odoo.your-site.example.com;

    # \u0110\u1ec3 certbot l\u1ea5y cert, sau \u0111\u00f3 certbot t\u1ef1 th\u00eam 301 redirect
    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }
}

server {
    listen 443 ssl http2;
    server_name odoo.your-site.example.com;

    # SSL cert s\u1ebd \u0111\u01b0\u1ee3c certbot inject

    proxy_read_timeout 720s;
    proxy_connect_timeout 720s;
    proxy_send_timeout 720s;

    proxy_set_header X-Forwarded-Host $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;

    client_max_body_size 100M;

    location /websocket {
        proxy_pass http://odoo_longpolling;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location / {
        proxy_pass http://odoo_http;
        proxy_redirect off;
    }

    gzip on;
    gzip_min_length 1100;
    gzip_buffers 4 32k;
    gzip_types text/css text/plain text/xml application/xml application/javascript application/json;
    gzip_vary on;
}

Enable site v\u00e0 xin cert:

sudo ln -s /etc/nginx/sites-available/odoo /etc/nginx/sites-enabled/
sudo nginx -t                    # test config
sudo systemctl reload nginx
sudo certbot --nginx -d odoo.your-site.example.com

Certbot s\u1ebd t\u1ef1 edit nginx config th\u00eam ssl_certificate + 301 redirect HTTP \u2192 HTTPS, \u0111\u1ed3ng th\u1eddi c\u00e0i cron job auto-renew ch\u1ea1y hai l\u1ea7n m\u1ed9t ng\u00e0y.

Hardening cu\u1ed1i c\u00f9ng

Khi m\u1ecdi th\u1ee9 ch\u1ea1y \u1ed5n, v\u00f4 hi\u1ec7u h\u00f3a database manager \u0111\u1ec3 tr\u00e1nh l\u1ed9 master password endpoint:

# Th\u00eam v\u00e0o config/odoo.conf
list_db = False

V\u00e0 thi\u1ebft l\u1eadp firewall:

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Restart Odoo: docker compose restart odoo. Truy c\u1eadp https://odoo.your-site.example.com \u2192 t\u1ea1o database \u0111\u1ea7u ti\u00ean qua wizard, sau \u0111\u00f3 t\u1eaft list_db.

Backup chi\u1ebfn l\u01b0\u1ee3c t\u1ed1i thi\u1ec3u

T\u1ea1o script ~/odoo-prod/backup.sh:

#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR=/var/backups/odoo
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"

# Dump database
docker compose exec -T db pg_dump -U odoo -F c postgres \
  > "$BACKUP_DIR/db-$TIMESTAMP.dump"

# Snapshot filestore
tar czf "$BACKUP_DIR/filestore-$TIMESTAMP.tar.gz" -C ./odoo-data .

# Gi\u1eef 7 ng\u00e0y g\u1ea7n nh\u1ea5t
find "$BACKUP_DIR" -name "*.dump" -mtime +7 -delete
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +7 -delete

C\u00e0i cron 0 2 * * * cd ~/odoo-prod && ./backup.sh \u0111\u1ec3 ch\u1ea1y 2h s\u00e1ng m\u1ed7i ng\u00e0y. Quan tr\u1ecdng nh\u1ea5t: \u0111\u1ecbnh k\u1ef3 test restore v\u00e0o staging \u2014 backup ch\u01b0a t\u1eebng restore kh\u00f4ng ph\u1ea3i l\u00e0 backup.

Khi n\u00e0o n\u00ean r\u1eddi docker-compose?

Stack n\u00e0y ph\u1ee5c v\u1ee5 t\u1ed1t \u0111\u1ebfn kho\u1ea3ng 50 concurrent user v\u00e0 1 instance Odoo. Khi nhu c\u1ea7u v\u01b0\u1ee3t qua \u0111\u00f3, c\u00e2n nh\u1eafc:

  • T\u00e1ch PostgreSQL ra managed service (DigitalOcean Managed DB, AWS RDS) \u2014 gi\u1ea3m r\u1ee7i ro m\u1ea5t data do disk failure tr\u00ean VPS
  • Chuy\u1ec3n sang Kubernetes n\u1ebfu ch\u1ea1y \u22655 instance v\u1edbi CI/CD multi-environment
  • D\u00f9ng load balancer \u0111\u1ee9ng tr\u01b0\u1edbc 2-3 Odoo container \u0111\u1ec3 failover

V\u1edbi 90% SME \u1edf Vi\u1ec7t Nam, single-VPS docker-compose \u0111\u1ee7 d\u00f9ng 2-3 n\u0103m tr\u01b0\u1edbc khi c\u1ea7n scale ngang.

References: