IHateMoney on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and configuration of IHateMoney on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. IHateMoney is a shared budget manager. It answers one question well: when a group of people keep paying for things on each other's behalf, who owes whom, and what is the simplest way to settle it?
You create a project for the flat, the holiday, the wedding or the band, add the people in it, and record expenses as they happen. Each expense records who paid and who it was for, so a bill shared by three of five participants is split between those three only. Participants can carry a weight, so someone who takes the larger room or the double portion absorbs a proportionate share. The balance view shows what each person has paid and where they stand; the settlement view reduces all of it to the shortest list of repayments that clears the board.
There is more underneath: multiple currencies with automatic conversion, reimbursement entries as well as expenses, a full history of every edit with the ability to undo, monthly statistics, CSV and JSON export, invitation links and email invites, a REST API that the mobile applications use, and an interface translated into more than thirty languages.
The cloudimg image ships the free and open source, BSD-3-Clause-style licensed IHateMoney 7.1.1, installed natively on Ubuntu 24.04: nginx serves the interface on port 80 and reverse proxies to a gunicorn worker on the loopback address, backed by PostgreSQL 16 which also listens only on loopback and whose data directory lives on a dedicated data disk. Backed by 24/7 cloudimg support.
IHateMoney is an independent open source project. This image is produced by cloudimg and is not affiliated with, endorsed by, or sponsored by the IHateMoney project. It ships the free and open source software, unmodified.

What is included:
- IHateMoney 7.1.1, the upstream release, pinned to the release commit
b911eb39rather than to the annotated tag object, and installed from the pinned release artifact - The version the installed distribution declares is asserted against the pin at build time and again after deployment, so a mislabelled upstream tag fails the build rather than shipping
- nginx on port
80serving the interface, installed as a host package so unattended security upgrades keep the internet facing component patched - gunicorn bound to
127.0.0.1:8000and PostgreSQL 16 bound to loopback, so the only thing exposed to your network is the web interface - The Flask session signing key, the administrator password and the PostgreSQL role password all generated per VM on first boot and written to a root only file
- Project creation closed behind the per VM administrator password, and upstream's world readable demonstration project switched off
- Fail closed guards: both the application service and nginx refuse to start if any secret is empty, unrotated, or a published upstream default
- The administrator password stored as a
pbkdf2hash, never in clear text - PostgreSQL's data directory on a dedicated data disk mounted at
/var/lib/ihatemoney, separate from the operating system disk - Outbound mail shipped off with empty credentials — no mail account is ever baked into the image
- The upstream licence text and the notices for every bundled front end component at
/usr/share/ihatemoney/ - Ubuntu 24.04 LTS base with latest security patches applied at build time and unattended security upgrades enabled
- Azure Linux Agent for seamless cloud integration and SSH key injection
- 24/7 cloudimg support with guaranteed 24 hour response SLA
Prerequisites
- An Azure subscription with permission to create virtual machines
- An SSH key pair for administrative access to the VM
- A network security group that allows inbound TCP
80from the networks you trust, and TCP22from your administrative network only - A
Standard_B2sVM (2 vCPU, 4 GiB RAM) is sufficient; IHateMoney, gunicorn and PostgreSQL all run comfortably in that footprint
Step 1: Deploy from the Azure Portal
- In the Azure Portal, search the Marketplace for IHateMoney on Ubuntu 24.04 LTS by cloudimg and select Create.
- Choose your subscription, resource group and region.
- Set the VM size to Standard_B2s or larger.
- Under Administrator account, select SSH public key and paste your public key. The administrative user is
azureuser. - Under Inbound port rules, allow HTTP (80) and SSH (22). Restrict both to your own address ranges wherever you can.
- On the Disks tab, Standard SSD is a good default for both the OS disk and the data disk.
- Select Review + create, then Create.
First boot takes about a minute: the appliance generates its own secrets, applies the database schema and proves it can answer a request before the web interface accepts anything.
Step 2: Deploy from the Azure CLI
Create the network, then the VM. Replace the placeholders with your own values.
# Placeholders: substitute your own resource group, region and address range.
az group create --name my-resource-group --location eastus
az network nsg create --resource-group my-resource-group --name ihatemoney-nsg
az network nsg rule create --resource-group my-resource-group --nsg-name ihatemoney-nsg \
--name allow-ssh --priority 1000 --destination-port-ranges 22 \
--source-address-prefixes <your-mgmt-cidr> --access Allow --protocol Tcp
az network nsg rule create --resource-group my-resource-group --nsg-name ihatemoney-nsg \
--name allow-http --priority 1010 --destination-port-ranges 80 \
--source-address-prefixes <your-mgmt-cidr> --access Allow --protocol Tcp
# Accept the image terms once per subscription, before the first deployment.
az vm image terms accept --publisher cloudimg --offer ihatemoney --plan default
az vm create \
--resource-group my-resource-group \
--name ihatemoney-01 \
--image cloudimg:ihatemoney:default:latest \
--size Standard_B2s \
--storage-sku StandardSSD_LRS \
--admin-username azureuser \
--generate-ssh-keys \
--nsg ihatemoney-nsg \
--public-ip-sku Standard
Step 3: Connect to your VM
ssh azureuser@<vm-ip>
Step 4: Confirm the appliance is running
Four services make up the appliance. ihatemoney-firstboot is a one shot that provisions this machine's own secrets; it stays active after it has completed because it is a RemainAfterExit unit.
systemctl is-active postgresql ihatemoney-firstboot ihatemoney nginx
Expected output:
active
active
active
active
Confirm the version that is actually deployed, rather than trusting a label:
sudo /opt/ihatemoney/venv/bin/python -c 'import importlib.metadata as m; print(m.version("ihatemoney"))'
Expected output:
7.1.1
Step 5: Retrieve your per VM credentials
Every secret on this machine was generated on this machine at first boot. Nothing is baked into the image and cloudimg does not know any of it.
sudo cat /root/ihatemoney-credentials.txt

The file is 0600 root:root, so only an administrator on the VM can read it. The value that matters most day to day is IHATEMONEY_ADMIN_PASSWORD: it is what unlocks project creation and the administration dashboard.
Confirm the file permissions for yourself:
sudo ls -l /root/ihatemoney-credentials.txt /etc/ihatemoney/ihatemoney.cfg
Step 6: What is exposed, and what is not
Only nginx on port 80 is reachable from your network. The application server and the database are bound to the loopback address, so they cannot be reached from outside the VM even if your network security group were wide open.
sudo ss -lntH | awk '{print $4}' | sort -u
You will see 127.0.0.1:8000 for the application and 127.0.0.1:5432 for PostgreSQL, while port 80 is bound on all addresses. Prove the difference from the VM itself, against its own routable address:
IPA=$(hostname -I | awk '{print $1}')
echo "nginx :80 -> HTTP $(curl -s -o /dev/null -w '%{http_code}' -m 10 http://$IPA/)"
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$IPA/8000" 2>/dev/null \
&& echo "app :8000 -> REACHABLE" || echo "app :8000 -> connection refused"
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$IPA/5432" 2>/dev/null \
&& echo "pgsql :5432 -> REACHABLE" || echo "pgsql :5432 -> connection refused"
Expected output:
nginx :80 -> HTTP 200
app :8000 -> connection refused
pgsql :5432 -> connection refused
Project creation is closed to anonymous visitors, and so is the REST API's project creation endpoint:
echo "GET /create -> $(curl -s -o /dev/null -w '%{http_code}' -m 10 http://127.0.0.1/create)"
echo "POST /api/projects -> $(curl -s -o /dev/null -w '%{http_code}' -m 10 -X POST \
-d 'name=probe&id=probe&password=probe123&contact_email=p@example.com' \
http://127.0.0.1/api/projects)"
Expected output — a redirect to the administrator gate, and a refusal from the API:
GET /create -> 303
POST /api/projects -> 400

Step 7: Sign in and create your first project
Browse to http://<vm-ip>/. You are offered a sign in for an existing project, and a link to create a new one.

Select Create a new project. Because this image closes public project creation, you are sent to the administrator gate first. Enter the IHATEMONEY_ADMIN_PASSWORD from Step 5.
Then fill in the project form:
- Project identifier — a short slug, for example
flatshare. This is the username for the project. - Name — a human readable name, for example
Flat share. - Private code — the shared password everyone in the project will use to open it.
- Email — where project reminders are sent.
Each participant opens the project with the identifier and the private code, not with individual accounts.

Step 8: Record expenses and settle up
Add the people in the project with Add participant, then use Add a new bill for each expense. For every bill you set who paid, what it was for, how much, and who it was for — that last field is what makes the split correct when a bill is not shared by everyone.
The example below is a flat share: rent, groceries and internet shared by all three, and one dinner shared by only two of them.

The sidebar keeps a running balance per person. A positive balance means the group owes them; a negative balance means they owe the group. The balances always sum to zero.
The Settle tab turns those balances into the shortest set of repayments that clears them:

Once a repayment has actually happened, Register settlement records it as a reimbursement so the balances update.
The same thing over the REST API
Every action above is available through the REST API, which is what the mobile applications use. A project authenticates with HTTP Basic using its identifier and private code. This worked example creates a project, adds three participants, records two expenses and reads back the computed balances.
ADMIN_PW='<IHATEMONEY_ADMIN_PASSWORD>'
PROJ=guidedemo
PPW=GuideDemo123
B=http://127.0.0.1
# Re-runnable: drop the demo project if a previous run left one behind.
curl -s -u "$PROJ:$PPW" -X DELETE "$B/api/projects/$PROJ" >/dev/null 2>&1 || true
# Project creation is administrator gated, so authenticate through the web form first.
J=$(mktemp); rm -f "$J"; P=$(mktemp)
csrf() { sed -n 's/.*name="csrf_token"[^>]*value="\([^"]*\)".*/\1/p' "$1" | head -1; }
curl -s -c "$J" -b "$J" "$B/admin" -o "$P"
curl -s -c "$J" -b "$J" -o /dev/null -d "csrf_token=$(csrf "$P")" \
-d "admin_password=$ADMIN_PW" "$B/admin"
curl -s -c "$J" -b "$J" "$B/create" -o "$P"
curl -s -c "$J" -b "$J" -o /dev/null -w 'create project -> HTTP %{http_code}\n' \
-d "csrf_token=$(csrf "$P")" -d 'name=Guide demo' -d "id=$PROJ" -d "password=$PPW" \
-d 'contact_email=demo@example.com' -d 'default_currency=XXX' "$B/create"
A="$B/api/projects/$PROJ"
ALICE=$(curl -s -u "$PROJ:$PPW" -X POST -d 'name=Alice' "$A/members" | tr -dc '0-9')
BOB=$(curl -s -u "$PROJ:$PPW" -X POST -d 'name=Bob' "$A/members" | tr -dc '0-9')
CAROL=$(curl -s -u "$PROJ:$PPW" -X POST -d 'name=Carol' "$A/members" | tr -dc '0-9')
TODAY=$(date -u '+%Y-%m-%d')
curl -s -u "$PROJ:$PPW" -X POST -d "date=$TODAY" -d 'what=Dinner' -d "payer=$ALICE" \
-d 'amount=90.00' -d 'original_currency=XXX' \
-d "payed_for=$ALICE" -d "payed_for=$BOB" -d "payed_for=$CAROL" "$A/bills" >/dev/null
curl -s -u "$PROJ:$PPW" -X POST -d "date=$TODAY" -d 'what=Taxi' -d "payer=$BOB" \
-d 'amount=30.00' -d 'original_currency=XXX' \
-d "payed_for=$ALICE" -d "payed_for=$BOB" -d "payed_for=$CAROL" "$A/bills" >/dev/null
curl -s -u "$PROJ:$PPW" "$A/statistics" | python3 -c '
import json, sys
for r in json.load(sys.stdin):
print("%-8s paid %7.2f balance %8.2f" % (r["member"]["name"], r["paid"], r["balance"]))
'
rm -f "$J" "$P"
Alice paid 90.00 and Bob paid 30.00, a total of 120.00 shared three ways, so each of them has consumed 40.00:
Alice paid 90.00 balance 50.00
Bob paid 30.00 balance -10.00
Carol paid 0.00 balance -40.00
Carol owes Alice 40.00 and Bob owes Alice 10.00, which is exactly what the Settle tab will propose.
Step 9: The administration dashboard
The image ships with the administration dashboard enabled and gated behind the same administrator password. Browse to http://<vm-ip>/dashboard and authenticate to see every project on the instance, its number of participants and bills, and to delete projects you no longer want.
Step 10: Where your data lives
PostgreSQL's data directory is on the dedicated data disk mounted at /var/lib/ihatemoney, so your data is on separate storage from the operating system.
findmnt -no SOURCE,TARGET,FSTYPE /var/lib/ihatemoney
sudo -u postgres psql -tAqw -c 'SHOW data_directory' </dev/null
The licence text and the attributions for every bundled front end component ship on the image:
ls -l /usr/share/ihatemoney/

Step 11: Back up and restore
Everything worth keeping is in PostgreSQL. Take a logical dump:
sudo -u postgres pg_dump -Fc ihatemoney > /var/tmp/ihatemoney-$(date -u +%Y%m%d).dump
ls -lh /var/tmp/ihatemoney-*.dump
Copy the dump off the VM, then restore it on a fresh appliance with:
# On the REPLACEMENT VM, after its own first boot has completed.
sudo systemctl stop ihatemoney
sudo -u postgres pg_restore --clean --if-exists -d ihatemoney /var/tmp/<your-dump-file>
sudo systemctl start ihatemoney
Because the signing key and administrator password are generated per VM, the replacement machine keeps its own credentials — restoring data does not carry the old secrets across.
Step 12: Enable invitation email
Outbound mail ships switched off and with empty credentials: cloudimg never bakes a mail account into an image. Project invitations and password reminders need a mail server you control. Edit the configuration file with your preferred editor:
sudo nano /etc/ihatemoney/ihatemoney.cfg
Set the mail keys to your own server, and turn off the suppression that ships by default:
MAIL_SERVER = "<your-smtp-host>"
MAIL_PORT = 587
MAIL_USE_TLS = True
MAIL_USERNAME = "<your-smtp-username>"
MAIL_PASSWORD = "<your-smtp-password>"
MAIL_DEFAULT_SENDER = "Budget manager <your-from-address>"
MAIL_SUPPRESS_SEND = False
Then restart the application:
sudo systemctl restart ihatemoney
A mail failure is never fatal — IHateMoney reports it and carries on, so a misconfigured server will not stop anyone using the project.
Step 13: Put HTTPS in front
The appliance serves plain HTTP on port 80. To terminate TLS on the VM itself, install certbot and point it at your own DNS name:
sudo apt-get update && sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d <your-domain>
After TLS is working, tighten the session cookie. The image ships SESSION_COOKIE_SECURE = False because a cookie marked Secure is never sent by a browser over plain HTTP, which would make every sign in silently fail. Once you are serving HTTPS, set it back:
sudo sed -i 's|^SESSION_COOKIE_SECURE = .*|SESSION_COOKIE_SECURE = True|' \
/etc/ihatemoney/ihatemoney.cfg
sudo systemctl restart ihatemoney
If you terminate TLS on an Azure Application Gateway or another reverse proxy instead, do the same thing — the setting follows how the browser reaches the site, not how nginx is configured.
Step 14: Day to day operation
Check the service and read its log:
systemctl status ihatemoney --no-pager | head -12
sudo journalctl -u ihatemoney -n 20 --no-pager -o cat
Restart after a configuration change:
sudo systemctl restart ihatemoney
The appliance applies its own database migrations on start, so an upgrade needs no separate schema step.
Troubleshooting
The web interface returns 502. The application service is not running. Its fail closed guard refuses to start when a secret is missing or is a published default, which is deliberate. Check why:
sudo /usr/local/sbin/ihatemoney-preflight.sh; echo "guard exit: $?"
An exit of 0 means the configuration is safe and the fault is elsewhere; read journalctl -u ihatemoney.
I cannot create a project. Project creation is gated behind the administrator password from /root/ihatemoney-credentials.txt, by design — otherwise anyone who can reach the address could create projects on your instance. Browse to /admin, authenticate, then create.
I have lost the private code for a project. Open the administration dashboard at /dashboard with the administrator password; from there you can delete a project. The private code itself is stored hashed and cannot be recovered, but the password reminder link on the sign in page will email it to the project's contact address if you have configured mail.
Sign in appears to succeed but I stay signed out. That is the Secure cookie problem described in Step 13, in reverse: it happens if SESSION_COOKIE_SECURE is True while you are browsing over plain HTTP. Either serve HTTPS or set it back to False.
Support
Backed by 24/7 cloudimg support with a guaranteed 24 hour response SLA. Contact support@cloudimg.co.uk.