Best Self-Hosted Retail POS Software with No Monthly Fees (2026 Guide)
Published 6 September 2026. Every figure below was measured by deploying Odoo 18 Community and PostgreSQL 16 on one live server we operate — a 2 vCPU, 7.8 GiB Ubuntu 24.04 VPS already running five production sites — installing the point-of-sale modules, benchmarking the result, and removing it. Where we could not test something on real hardware, we say so rather than describing it as if we had.
1. What the Subscription Actually Costs Over Three Years
The case for a self-hosted till is not that it is free. It is that the cost stops growing. A hosted point of sale charges per register, per month, forever; an owned one charges once, plus a server.
| Two registers, three years | Hosted POS | Owned stack |
|---|---|---|
| Software subscription | $89 × 2 × 36 = $6,408 | $0 |
| Deployment | Included | $699 once |
| Server | Included | $9 × 36 = $324 |
| Card processing | Charged by the platform | Charged by your processor |
| Three-year total | $6,408 | $1,023 |
card processing is not a saving. You pay a processor either way — the Stripe Reader S700 charges Stripe’s rate whether it talks to Odoo or to Shopify. Comparisons that fold processing fees into the “savings” column inflate the number by thousands, and the first merchant who reads their own statements will notice. What self-hosting removes is the software subscription, and on two registers that is enough.
The row that comparisons leave out is maintenance. A self-hosted till does not update itself, back itself up, or renew its own certificate. That work is real; it is either your Sunday or someone’s retainer. Price it in before deciding.
2. What the Stack Actually Consumes
We deployed it and measured, because “runs on a small VPS” is the kind of claim that turns out to mean something different once the point-of-sale modules are installed.
| Step | Measured |
|---|---|
| Images pulled | odoo:18 2.96 GB + postgres:16 642 MB, in 44 s |
| Cold start to first HTTP 200 | 5 s |
| Database creation | 19 s |
| Installing POS, stock and sales | 77 s, 67 modules |
| Database after install | 58 MB, 509 tables |
| Memory, single process | Odoo 129 MiB + PostgreSQL 108 MiB = 237 MiB |
| Memory, 3 workers | Odoo 423 MiB + PostgreSQL 113 MiB = 536 MiB |
Half a gigabyte of RAM for a working till with three worker processes. That fits comfortably on a 2 GB VPS and leaves room on a 4 GB one for the storefront alongside it. The 2.96 GB image is the surprise — budget disk for it, not memory.
3. The docker-compose.yml, Complete
This is the file we ran, with the PostgreSQL tuning that matters on a small machine. Nothing is elided.
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
POSTGRES_DB: postgres
command:
- postgres
# Tuned for a 2 vCPU / 4 GB host. shared_buffers at 25% of the RAM
# you are willing to give PostgreSQL, not 25% of the machine.
- -c
- shared_buffers=256MB
- -c
- work_mem=8MB
- -c
- maintenance_work_mem=128MB
- -c
- effective_cache_size=768MB
# Odoo opens one connection per worker plus cron. 64 is generous.
- -c
- max_connections=64
volumes:
- odoo-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U odoo"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 1g
odoo:
image: odoo:18
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
# Loopback only. Docker writes iptables rules that are consulted
# BEFORE UFW, so a port published on all interfaces is reachable
# from the internet even when your firewall says it is closed.
# nginx terminates TLS and proxies to these.
- "127.0.0.1:8069:8069"
- "127.0.0.1:8072:8072"
environment:
HOST: db
USER: odoo
PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
volumes:
- odoo-data:/var/lib/odoo
- ./config:/etc/odoo
- ./addons:/mnt/extra-addons
deploy:
resources:
limits:
memory: 2g
volumes:
odoo-db:
odoo-data:Create the .env beside it with a generated password, and never commit it:
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" > .env
chmod 600 .env
printf '.env\n' >> .gitignore4. The Port Nobody Warns You About
Every Odoo nginx guide tells you to proxy /websocket to port 8072. On a default install that produces a 502, and the reason is not in most of those guides.
We checked both ports on a freshly started container with no worker configuration:
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8069/web/login
200
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8072/web/login
000 # nothing is listeningPort 8072 does not exist until you configure workers. In single-process mode Odoo serves websockets on 8069 alongside everything else; the dedicated gevent listener only starts when workers is greater than zero. Adding the three lines below made it appear:
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8072/web/login
200
$ curl -o /dev/null -w '%{http_code}\n' \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
http://127.0.0.1:8072/websocket
400 # endpoint exists, rejects an unauthenticated handshake — correctThe cost of that port existing is memory: Odoo went from 129 MiB to 423 MiB, because three workers, a cron thread and the gevent listener are five processes instead of one. In exchange, throughput on the login page rose from 25.7 to 35.4 requests per second at concurrency 5, and the P50 fell from 186 ms to 127 ms — about 38% more capacity for 294 MiB.
Production odoo.conf
[options]
; Generate this, never leave it as "admin" — it authorises database
; creation and deletion over HTTP.
admin_passwd = REPLACE_WITH_openssl_rand_hex_16
db_host = db
db_port = 5432
db_user = odoo
db_password = REPLACE_WITH_THE_SAME_VALUE_AS_DB_PASSWORD
; Pin the instance to one database. Without this, /web/database/selector
; is reachable and your database list is public.
dbfilter = ^retail$
list_db = False
; Required behind nginx, or Odoo builds redirect URLs with the wrong host.
proxy_mode = True
; The rule of thumb is (cores * 2) + 1. On 2 vCPU that is 5, but each
; worker cost us roughly 100 MiB — we measured 423 MiB total at 3 workers.
; Start at 3 and raise only if you see queueing.
workers = 3
max_cron_threads = 1
; A worker above the soft limit is recycled after the current request.
; Above the hard limit it is killed immediately. 640 MB / 768 MB suits a
; 4 GB host; halve both on a 2 GB one.
limit_memory_soft = 671088640
limit_memory_hard = 805306368
limit_time_cpu = 60
limit_time_real = 120nginx, with the WebSocket path that actually works
upstream odoo { server 127.0.0.1:8069; }
upstream odoo_chat { server 127.0.0.1:8072; }
server {
listen 443 ssl http2;
server_name pos.example.com;
ssl_certificate /etc/letsencrypt/live/pos.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pos.example.com/privkey.pem;
# Receipts and product images are the only large bodies here.
client_max_body_size 32M;
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;
# The long-poll bus holds a connection open. The nginx default of 60s
# closes it, the browser reconnects, and the till appears to "flicker"
# — this is the timeout behind most Odoo WebSocket complaints.
proxy_read_timeout 720s;
proxy_connect_timeout 720s;
proxy_send_timeout 720s;
location /websocket {
proxy_pass http://odoo_chat;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / {
proxy_pass http://odoo;
proxy_redirect off;
}
# Odoo fingerprints these; they can be cached hard and safely.
location ~* /web/static/ {
proxy_cache_valid 200 90m;
proxy_buffering on;
expires 864000;
proxy_pass http://odoo;
}
gzip on;
gzip_types text/css text/plain application/javascript application/json;
}5. Hardware, and What We Could Not Test
Here the article changes register, and it should. Everything above was measured on our server. The hardware below is what this stack is designed around — we did not have a thermal printer or a card reader on the bench, and we are not going to describe test output we never saw.
| Component | What matters when choosing |
|---|---|
| Receipt printer | ESC/POS over Ethernet, not USB. A network printer is reachable by any till on the LAN and survives the browser being restarted. The Epson TM-T88 family is the common choice because ESC/POS support is unambiguous. |
| Barcode scanner | Any USB scanner in keyboard-wedge mode. It types the code and sends Enter; no driver, no integration. Configure the suffix to carriage return. |
| Card reader | Whatever your processor supports. A Stripe Reader S700 works with Odoo’s Stripe Terminal integration; the point is that the reader is tied to your processor contract, not to your POS software. |
| Till machine | Any browser on the LAN. The heavy work happens on the server, which is the entire argument for this architecture. |
Network receipt printing, and the encoding trap
Odoo prints to a network ESC/POS printer by IP, configured once on the point-of-sale record. The failure everyone hits is not connectivity — it is characters. An ESC/POS printer is not Unicode; it holds a code page, and anything outside it prints as a wrong glyph or a blank.
the symptom is a receipt where accented product names, currency symbols or the shop’s own name come out mangled while everything else prints correctly. It is a code page mismatch, not a corrupted print job. Set the printer’s code page to match your language before blaming Odoo, and test with the longest accented product name you actually sell, not with “Test”.
We are describing this failure mode rather than demonstrating it, because we did not have the printer. Treat it as the first thing to check, not as a verified reproduction.
6. Troubleshooting, From What We Hit
The module install fails with a database connection error
Our first attempt to install the POS modules produced Is the server running locally and accepting connections on that socket? — from inside a container whose database was demonstrably up.
Running odoo directly bypasses the image’s entrypoint, which is what translates the HOST, USER and PASSWORD environment variables into connection parameters. Without it, Odoo looks for PostgreSQL on a local socket that does not exist. Pass them explicitly:
docker compose exec -T odoo odoo -d retail \
-i point_of_sale,stock,sale_management \
--db_host=db --db_user=odoo --db_password="$DB_PASSWORD" \
--stop-after-init --no-httpThat completed in 77 seconds and loaded 67 modules.
The till loses its connection every minute
Two separate causes produce the same symptom, and they need opposite fixes.
If /websocket returns 502, the gevent listener is not running: check that workers is above zero in odoo.conf, because on a default install nothing listens on 8072 at all. If it connects and then drops on a regular cycle, it is nginx closing an idle long-poll — raise proxy_read_timeout, which defaults to 60 seconds against a bus that holds connections far longer.
Workers are restarting under load
Check the log for the memory limits before assuming a bug. A worker that crosses limit_memory_soft is recycled after its current request, which is graceful and correct; one that crosses limit_memory_hard is killed immediately, and a customer at the counter sees that. If it happens during normal trading, the limits are too low for your catalogue rather than the machine being too small — raise the soft limit first and watch.
7. Frequently Asked Questions
How much RAM does a self-hosted Odoo 18 POS need?
We measured 237 MiB total in single-process mode and 536 MiB with three workers, PostgreSQL included, after installing point of sale, stock and sales. A 2 GB VPS runs it comfortably; 4 GB leaves room for the storefront alongside. Disk matters more: the Odoo image alone is 2.96 GB.
Why does Odoo return 502 on the /websocket path?
Because port 8072 does not exist unless workers are configured. On a default single-process install, nothing listens there — we measured a bare connection failure, not a refusal. Set workers above zero in odoo.conf, restart, and the gevent listener appears. Memory rose from 129 MiB to 423 MiB when we did.
Does self-hosting a POS remove card processing fees?
No, and any comparison claiming otherwise is inflating its numbers. You pay a payment processor whichever software runs the till. What self-hosting removes is the per-register software subscription, which on two registers over three years came to $6,408 against $1,023 for an owned stack in our costing.
How long does deploying Odoo 18 with POS take?
On our server, 44 seconds to pull the images, 5 seconds to first HTTP response, 19 seconds to create the database and 77 seconds to install the point-of-sale modules. Under three minutes of machine time. The configuration, the reverse proxy and the printer are what actually take the afternoon.
Why do accented characters print wrong on the receipt?
ESC/POS printers are not Unicode devices; they hold a code page, and characters outside it print as wrong glyphs or blanks. Set the printer’s code page to match your language, then test with the longest accented product name you genuinely sell. The rest of the receipt printing correctly is what makes this look like a software fault.
8. The Short Version
- 536 MiB of RAM with three workers, PostgreSQL included. Disk is the real constraint at 2.96 GB for the image.
- Port 8072 does not exist until you set
workers. That single line is behind most WebSocket 502s. - Workers cost 294 MiB and returned 38% more throughput. Worth it above one register.
- Card processing fees are not a saving. The software subscription is.
- Maintenance is the honest cost of ownership. Price it in, or buy it.
If you would rather have this delivered than built, our Retail-in-a-Box packages cover exactly this stack, and the pricing page lists the deployment tiers. For what else fits on the same server, our capacity model across seven applications puts these numbers in context.