Sometimes, many times, perhaps most of the time, you have to work with what you have and not with what you want. It’s the eternal battle between what should be and what is, that pinch of reality, but also of challenge that gives meaning to life, looking at it optimistically, haha. It generally applies to small and medium-sized companies, where their focus is on marketing, finance, or production, and IT is in the basement, at a small desk, with a 90s coffee maker as a server.

We’ll be working on an HP Proliant ML110 G3 server (from 2005!!!) with a Xeon E31220 processor and 10gb of RAM, Windows Server 2022, which is a file server, domain, and a small MySQL database.

Problem: The system they use for sales is a very basic program that has fallen short for the company. It’s an application made with WinDev that connects to the MySQL database to register orders, and that’s it; nowadays they face hand-written orders, to later enter them into the system, so it results in deliveries outside dates, wrong items, lost orders, no certain knowledge of margins and not to mention data-driven decisions. I mean, yes, it’s a small company, in a small city, but that’s no excuse not to do things right, and we’ll solve it ;)

We have the main plant and two branches, one of them in another city, so communication and planning is important.

Material: The old server and great enthusiasm.

Plan: We’ll create an intranet connecting the branches through a VPN and I’ll implement the Odoo system in its Community version: CRM, Inventory, Chat, Manufacturing Orders, Accounting, HR, and a long etcetera. It’s the first time I’m using it but it looks very promising.

We’ll use Hyper-V, where we’ll have the Virtual Machine (VM) running Oracle Linux Server; not for anything special, personal taste, I like the package manager and that it’s solid as a rock. Here we’ll have the infra, easy to move in case you want to update the hardware soon and the migration is quick, without pausing the production area.

The system will be used by an average of approximately 20 concurrent users.

We’ll orchestrate the Odoo 18, PostgreSQL 15 and Nginx services using Docker and docker compose. The host server has two network cards. One card with its IP will stay for the use of the domain server, dns, files and, for now, the MySQL database of that ugly program. The second card we’ll use for our VM. We’ll use the domain’s DNS server to point to the VM’s IP, ex: odoo.domain.local, for greater pleasure gg.

Let’s get to work. ( I’ll get straight to the point from the start to not make this article infinite C: )

Install Hyper-V

The basis of all this is our virtual machine, we go to our Server Manager > Add roles and features. We select Hyper-V if we don’t have it installed and select next next next next… it’s a good idea to restart afterwards if possible.

Create virtual switch (Hyper-V)

Now we have our Hyper-V Manager available (virtmgmt.msc). We open it and, in the right panel, or in the actions menu we select the Virtual Switch Manager. Here we’ll create our WAN network using the second network card. Select New virtual network switch, External and select the card you want to use and the checkbox below, Allow the management operating system to share…

Create the Virtual Machine

Now you should select the operating system you want to use, as I mentioned I’ll use Oracle Linux.

Once the ISO image is downloaded, in the Hyper-V Manager, in the right panel, or from file we’ll click New to create our new Virtual Machine. That’s why you click where it says Virtual Machine after clicking New.

Give it a nice name and, if you want to put the VM in another location, select there where you want it, in my case on another drive since C:\ I only use for Windows.

In specify generation I still use 1, the other one gave me trouble and well… haha.

I assigned 4096 MB (4Gb) of RAM, and selected dynamic just in case, anyway, I want the entire service infrastructure to run on the VM in the end, using containers inside, in your case it will depend on your requirements and available hardware.

In Configure networking, in connection choose the switch we created earlier WAN.

In Connect virtual hard disk, well just put the Size, which here doesn’t matter as much since it grows dynamically, in my case I put 50 Gb and from there onwards until it fills up.

In installation options, select install from boot CD, ISO image file and select it. Click finish.

Anyway all this can be edited later, disk size, assigned memory, network, installation media, add more hardware, CPU cores, and more.

Operating System Installation

Install the operating system on the VM :v

Docker and Docker Compose Installation and configuration

Install and configure Docker and Docker Compose on the VM 🤡

Odoo Time 😁

Assuming you already have your favorite linux distribution installed on the virtual machine and you installed Docker and Docker Compose, we’ll start running the containers using our docker-compose.yml file

If everything has gone well so far you should have something like this:

We’ll create a directory where we’ll place our project, in my case inside the Infra folder and we’ll place our docker-compose.yml file inside to do its thing there.

mkdir -p ~/Infra/docker-compose/dc_odoo

The easy way is to install git on your VM and do a git clone:

git clone https://github.com/rigelcarbajal/odoo-compose.git

This will download a series of files and directories, well just three gg, we’ll review them:

# docker-compose.yml

services:
  web:
    image: odoo:18    # You can put latest or any other version
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "8069:8069"
      - "8072:8072"
    volumes:
      - odoo-web-data:/var/lib/odoo
      - ./config:/etc/odoo
      - ./addons:/mnt/extra-addons
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8069"]
      interval: 15s
      timeout: 10s
      retries: 5
    restart: unless-stopped
    networks:
      - odoo-net
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"

  db:
    image: postgres:15    # It's the most compatible version with odoo at the moment
    environment:
      - POSTGRES_DB=postgres
      - POSTGRES_PASSWORD=odoo
      - POSTGRES_USER=odoo
      - PGDATA=/var/lib/postgresql/data/pgdata
    volumes:
      - odoo-db-data:/var/lib/postgresql/data/pgdata
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "odoo"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    networks:
      - odoo-net

  nginx:    # ...
    image: nginx:stable
    depends_on:
      - web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./nginx/certs:/etc/nginx/certs
    networks:
      - odoo-net
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/health"]
      interval: 15s
      timeout: 5s
      retries: 3

volumes:
  odoo-web-data:
  odoo-db-data:

networks:
  odoo-net:
# config/odoo.conf
# This is the odoo configuration

[options]

# Change the password, it's the Master Password of Odoo / Initial web configuration
admin_passwd = SuperPassword123

db_host = db
db_port = 5432
db_user = odoo
db_password = odoo

addons_path = /mnt/extra-addons

# Depending on your server hardware modify these values, these are low
db_maxconn = 50
limit_memory_soft = 1536000000
limit_memory_hard = 3072000000
limit_time_cpu = 60
limit_time_real = 120
limit_request = 8192
max_cron_threads = 2
workers = 2

# For chat, but this is legacy, currently uses websockets
longpolling_port = 8072

logfile = /var/log/odoo/odoo.log
logrotate = True
log_level = info

xmlrpc_port = 8069
# Because we use nginx
proxy_mode = True
# nginx/config.d/odoo.conf
# This nginx proxy configuration, here it's using port 80 since it's -
# for an intranet, if you want to expose it you should put it using SSL/TLS using -
# port 443 and your corresponding certificates

server {
    listen 80;
    server_name odoo.local;

    access_log /var/log/nginx/odoo.access.log;
    error_log /var/log/nginx/odoo.error.log;

    location / {
        proxy_pass http://web:8069;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 600;
        proxy_send_timeout 600;
        proxy_read_timeout 600;
        send_timeout 600;
    }

# Legacy / old, but better not touch it
    location /longpolling/ {
        proxy_pass http://web:8072;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

# This one is important so the chat doesn't break
    location /websocket {
        proxy_pass http://web:8072;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

# This is to know its status using docker ps
    location /health {
        return 200 "OK\n";
        access_log off;
    }

# To optimize a little just ...
    gzip on;
    gzip_min_length 1000;
    gzip_types text/plain application/xml application/json application/javascript text/css text/js;
}

Once the previous files are reviewed, and seeing that it makes sense for your specific case we’ll use:

docker compose up -d

To bring up the services, we can verify that everything is running well using:

docker ps

The three containers say healthy, if not we’ll have to (you lol) do a bit of troubleshooting to fix it, but in theory everything is correct…

… and it should be accessible on the local network from the_server_ip:8069 and start using it, but this looks ugly…

Configuring local DNS

… imagine you have a DNS server in your organization, in my case Active Directory with the name organization.local; let’s take advantage to put it as odoo.organization.local instead of ip:port

We run dnsmgmt.msc to open the DNS Manager, you can also from Server Manager and Tools. Here we’re already working on our host, in my case with Windows Server 2022 in case there’s someone lost out there.

Select your Active Directory organization and in Forward Lookup Zone again your organization’s name. In the right panel right-click and choose New Host (A or AAAA). In the gray box, in Name put what you like, in my case odoo, it’s the subdomain; in Fully qualified domain name it just previews how it will look. In IP the address of your server where Odoo is running, the IP of the virtual machine, will depend on your configuration, you can use on your VM:

ifconfig
# or
ip a

to know your IP, CAREFUL! maybe you need to open port 80 or 443 on your Linux.

With that configured you can now access odoo.organization.local on your intranet.

And done, it says not secure because it doesn’t use SSL certificate (http:80 and not https:443), if you’re going to expose it on the internet yes use SSL mandatory without excuse :) (maybe later, if you behave well, I’ll upload how it would be with SSL certificate).

I hope this is helpful, bye =)

UPDATE!

I’ve added support to use SSL since it looked very ugly the “Site not secure” or those warnings.

The new code would be as follows, you can now download it from GitHub;

CAREFUL, if you don’t want to just delete the first server that says listen 443 ssl and leave the one below that says listen 80.

server {
    listen 443 ssl;
    server_name odoo.local;

    ssl_certificate     /etc/nginx/certs/ecdsa.crt;
    ssl_certificate_key /etc/nginx/certs/ecdsa.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    access_log /var/log/nginx/odoo.access.log;
    error_log /var/log/nginx/odoo.error.log;

    client_max_body_size 50M;

    location / {
        proxy_pass http://web:8069;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 600;
        proxy_send_timeout 600;
        proxy_read_timeout 600;
        send_timeout 600;
    }

    location /longpolling/ {
        proxy_pass http://web:8072;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location /websocket {
        proxy_pass http://web:8072;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        add_header Access-Control-Allow-Origin "*";
        add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
        add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept";

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location /health {
        return 200 "OK\n";
        access_log off;
    }

    gzip on;
    gzip_min_length 1000;
    gzip_types text/plain application/xml application/json application/javascript text/css text/js;
}

server {
    listen 80;
    server_name odoo.local;

    access_log /var/log/nginx/odoo.access.log;
    error_log /var/log/nginx/odoo.error.log;

    client_max_body_size 50M;

    location / {
        proxy_pass http://web:8069;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 600;
        proxy_send_timeout 600;
        proxy_read_timeout 600;
        send_timeout 600;
    }

    location /longpolling/ {
        proxy_pass http://web:8072;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location /websocket {
        proxy_pass http://web:8072;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location /health {
        return 200 "OK\n";
        access_log off;
    }

    gzip on;
    gzip_min_length 1000;
    gzip_types text/plain application/xml application/json application/javascript text/css text/js;
}

Remember to execute or create the key and crt files in the nginx/certs/ folder, I left a script there to generate them, but here’s another version where you can improve the certificate by adding extra DNS’s and IP’s:

#!/bin/bash

# === Configuration ===
CN="odoo.local"
CERT_NAME="odoo_ecdsa"
CURVA="secp384r1"         # Stronger than prime256v1
DAYS=730                  # 2 years
DIR=$(pwd)

# === Files ===
KEY_FILE="$DIR/$CERT_NAME.key"
CRT_FILE="$DIR/$CERT_NAME.crt"
CONF_FILE="$DIR/$CERT_NAME.cnf"

# === Create OpenSSL configuration file ===
cat > "$CONF_FILE" <<EOF
[req]
prompt = no
default_md = sha384
distinguished_name = dn
req_extensions = v3_req

[dn]
CN = $CN

[v3_req]
subjectAltName = @alt_names

[alt_names]
DNS.1 = odoo.local
DNS.2 = odoo.empresa.local
DNS.3 = localhost
IP.1  = 127.0.0.1
IP.2  = 192.168.1.20
EOF

# === Generate ECDSA private key ===
openssl ecparam -genkey -name "$CURVA" -out "$KEY_FILE"

# === Generate self-signed certificate ===
openssl req -new -x509 \
  -key "$KEY_FILE" \
  -out "$CRT_FILE" \
  -days "$DAYS" \
  -config "$CONF_FILE" \
  -extensions v3_req

# === Show result ===
echo " Certificate generated:"
echo "= Key: $KEY_FILE"
echo "=� Certificate: $CRT_FILE"

# === Optional cleanup ===
# rm "$CONF_FILE"

A bit of troubleshooting: you’ll get an error at [https://]odoo[.]local, something like: “connection timed out in real time”. It took me a while to figure out why, but it’s because you need to add the nginx/certs/ecdsa.crt certificate to your client computers.

What I did was create a new group policy on my domain server where I put the certificate, and that group policy I applied to my entire domain so it adds the certificate to the computers that are part of it and done, it now appears as a secure site and looks nice.