Ih
Applications Azure

IHateMoney on Ubuntu 24.04 on Azure User Guide

| Product: IHateMoney on Ubuntu 24.04 LTS on Azure

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.

The postgresql, ihatemoney-firstboot, ihatemoney and nginx services all reporting active, the deployed distribution declaring version 7.1.1, and the listening sockets showing the application on 127.0.0.1:8000 and PostgreSQL on 127.0.0.1:5432 with only nginx on port 80 exposed

What is included:

  • IHateMoney 7.1.1, the upstream release, pinned to the release commit b911eb39 rather 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 80 serving the interface, installed as a host package so unattended security upgrades keep the internet facing component patched
  • gunicorn bound to 127.0.0.1:8000 and 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 pbkdf2 hash, 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 80 from the networks you trust, and TCP 22 from your administrative network only
  • A Standard_B2s VM (2 vCPU, 4 GiB RAM) is sufficient; IHateMoney, gunicorn and PostgreSQL all run comfortably in that footprint

Step 1: Deploy from the Azure Portal

  1. In the Azure Portal, search the Marketplace for IHateMoney on Ubuntu 24.04 LTS by cloudimg and select Create.
  2. Choose your subscription, resource group and region.
  3. Set the VM size to Standard_B2s or larger.
  4. Under Administrator account, select SSH public key and paste your public key. The administrative user is azureuser.
  5. Under Inbound port rules, allow HTTP (80) and SSH (22). Restrict both to your own address ranges wherever you can.
  6. On the Disks tab, Standard SSD is a good default for both the OS disk and the data disk.
  7. 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 credentials file at mode 0600 owned by root and the configuration file at 0640 owned by root and the ihatemoney service account, listing the appliance URL, administrator password, database name, database user, database password and session signing key generated uniquely on this VM

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

An anonymous request to slash create answering HTTP 303 with a redirect to the administrator gate, an anonymous API project creation answering HTTP 400, nginx answering HTTP 200 on the VM's own routable address while the application port and PostgreSQL port both refuse connections there, and the fail closed guard rejecting the published upstream signing key tralala

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.

The IHateMoney landing page offering log in to an existing project with project identifier and private code fields, alongside a create a new project panel

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.

The project sign in page asking for the private code that opens an existing project

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 project bill list showing four expenses with their per person splits, rent of 1,200.00 split 400.00 each, groceries of 84.50 split 28.17 each, internet of 45.00 split 15.00 each and a dinner of 96.00 split 48.00 each between two people, with the sidebar showing balances of Alice plus 804.83, Bob minus 406.67 and Carol minus 398.17

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:

The settlement view listing who pays whom, Carol paying Alice 398.17 and Bob paying Alice 406.67, each with a register settlement button, alongside the same balances in the sidebar

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/

The data disk mounted at slash var slash lib slash ihatemoney as ext4 with the PostgreSQL data directory inside it, and the licence directory listing the LICENSE and NOTICE files with the opening lines of the BSD three clause style licence

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.