WAL-G on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and operation of WAL-G on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. WAL-G is an archival and restore tool for PostgreSQL. It continuously streams write ahead log (WAL) and takes full and delta base backups to storage, compresses and encrypts everything it writes, and restores a cluster to any point in time.
WAL-G on its own is a command line binary, so this image is not a bare binary drop. It is a complete PostgreSQL backup and point in time recovery host: a pinned PostgreSQL 18.4 server, WAL-G 3.0.8 configured against it, continuous WAL archiving already wired through archive_command = 'wal-g wal-push %p', an encrypted store on a dedicated data volume, and a working schedule of base backups and retention pruning. PostgreSQL is installed from the official PostgreSQL Global Development Group (PGDG) apt repository, whose signing key is verified by its full fingerprint at build time; the WAL-G binary is the official upstream release, verified by SHA-256 before install.
Ready on first boot. On the first boot of every virtual machine the image generates a unique PostgreSQL superuser password and a unique WAL-G libsodium encryption key, writes the per machine WAL-G configuration, takes an initial base backup, and confirms it is listed. The machine is protecting data before you log in.
Security by design — nothing exposed. PostgreSQL is configured listen_addresses = 'localhost', so it binds 127.0.0.1 only. This image ships no network reachable database port; the only inbound port is SSH 22. pg_hba.conf is restrictive and contains no trust rule anywhere: peer for the local postgres superuser, scram-sha-256 for every other path.
Security by design — no baked credential. No password and no encryption key ship in the image. Both are generated per virtual machine on first boot and written to /root/wal-g-credentials.txt, readable only by root. The image also carries no backup data: every build time backup and archived WAL segment is destroyed before capture.
What is included:
-
PostgreSQL 18.4 (pinned, from the official PGDG repository, signing key verified by full fingerprint) and WAL-G 3.0.8 (official upstream release, SHA-256 verified)
-
Continuous WAL archiving via
archive_command = 'wal-g wal-push %p'into a libsodium-encrypted store on a dedicated 64 GiB data volume mounted at/var/lib/wal-g -
Scheduled daily base backups and weekly retention pruning, as systemd timers
-
A shipped self test that takes a backup, writes a row, and recovers it by replaying archived WAL into a scratch datadir — so point in time recovery can be proven rather than trusted
-
A documented path to repoint the store at Azure Blob Storage for off box durability
-
Unattended security upgrades left enabled so the OS keeps receiving patches
Prerequisites
-
Active Azure subscription, SSH public key, VNet and subnet in the target region
-
Subscription to the WAL-G listing on Azure Marketplace
-
A Network Security Group rule allowing TCP 22 for administration
Recommended virtual machine size: Standard_B2s (2 vCPU, 4 GB RAM) is sufficient for the backup host itself. Protecting large or busy databases benefits from more CPU for compression and encryption — Standard_D2s_v5 or larger — and from a larger data disk.
Step 1: Deploy from the Azure Portal
Search WAL-G in Marketplace, select the cloudimg publisher, and click Create. Configure the Network Security Group to allow TCP 22 for administration only. Do not open TCP 5432: this image deliberately binds PostgreSQL to loopback, so there is nothing listening on that port to reach.
The image declares a dedicated data disk for the backup store, which Azure provisions and mounts at /var/lib/wal-g automatically. Size it for your retention policy before you deploy.
Step 2: Deploy from the Azure CLI
RG="database-prod"; LOCATION="eastus"; VM_NAME="walg1"
GALLERY_IMAGE_ID="/subscriptions/<sub-id>/resourceGroups/azure-cloudimg/providers/Microsoft.Compute/galleries/cloudimgGallery/images/wal-g/versions/<version>"
SSH_KEY="$(cat ~/.ssh/id_rsa.pub)"
az group create --name "$RG" --location "$LOCATION"
az vm create \
--resource-group "$RG" --name "$VM_NAME" \
--image "$GALLERY_IMAGE_ID" \
--size Standard_B2s \
--admin-username azureuser \
--ssh-key-values "$SSH_KEY" \
--public-ip-sku Standard
# Open the administration port only. Do NOT open 5432 — PostgreSQL is loopback bound.
az vm open-port --resource-group "$RG" --name "$VM_NAME" --port 22 --priority 1001
Step 3: First boot
On first boot the image asserts the store volume is mounted, generates the per virtual machine PostgreSQL password and WAL-G libsodium key, writes the per machine /etc/wal-g/wal-g.env, starts PostgreSQL, takes an initial base backup, confirms it is listed, and enables the backup timers. This completes within a minute or two. SSH in as azureuser and read the credentials:
sudo cat /root/wal-g-credentials.txt
The file records the PostgreSQL superuser password, the WAL-G libsodium key, the store path and the host address. It is 0600 root:root, so only root can read it.
Back up the WAL-G libsodium key somewhere safe. Every archived backup and WAL segment is encrypted with this per machine key. Without it your backups cannot be decrypted, and therefore cannot be restored.
Step 4: Confirm the service and the security posture
PostgreSQL is running, WAL-G and PostgreSQL report the pinned versions, and the listener set confirms the database is bound to loopback while the only externally reachable port is SSH.
systemctl is-active postgresql@18-main.service
wal-g --version
psql --version
ss -tlnH | awk '{print $4}' | sort -u
Expected output:
active
wal-g version v3.0.8 f81943e 2026.01.21_14:26:12 PostgreSQL
psql (PostgreSQL) 18.4
0.0.0.0:22
127.0.0.1:5432
127.0.0.53%lo:53
127.0.0.54:53
[::]:22
127.0.0.1:5432 is PostgreSQL on loopback; 127.0.0.53 and 127.0.0.54 are the systemd resolver stub, also loopback. Only port 22 is bound to a routable address.

Step 5: Inspect the store
WAL-G runs as the postgres user and reads its configuration from /etc/wal-g/wal-g.env. backup-list shows every base backup in the store; the initial base backup taken at first boot is already there.
findmnt -no SOURCE,FSTYPE,SIZE,TARGET /var/lib/wal-g
sudo -u postgres wal-g backup-list
Expected output:
/dev/sdc ext4 62.4G /var/lib/wal-g
name modified wal_segment_backup_start
base_000000010000000000000002 2026-08-08T05:42:07Z 000000010000000000000002
The store lives on its own dedicated volume, and every object in it is encrypted with the per machine libsodium key. wal-g wal-verify integrity walks the archived WAL and confirms there are no gaps in the timeline.
sudo -u postgres wal-g wal-verify integrity

Step 6: The backup workflow
The WAL-G workflow is a handful of commands. WAL is archived continuously by PostgreSQL through archive_command; you take base backups on top of that stream.
backup-push takes a full base backup of the data directory. WAL-G streams it to the store, compressed and encrypted.
sudo -u postgres wal-g backup-push /var/lib/postgresql/18/main
backup-list lists the base backups in the store. Add --json for machine readable output.
sudo -u postgres wal-g backup-list
wal-verify re-reads the archived WAL and confirms the timeline is intact, so a gap is caught before you need the backup rather than during a restore.
sudo -u postgres wal-g wal-verify integrity

Step 7: Restore and point in time recovery
A backup you have never restored is a hypothesis. WAL-G restores by fetching a base backup into an empty data directory and then replaying archived WAL on top of it, which is how point in time recovery works: the base gives you a starting point, and the WAL stream carries you forward to any moment it covers.
The safest way to prove a restore without disturbing your running cluster is to restore into a separate scratch data directory on a private socket. This is exactly what the shipped self test does (Step 8), and the outline is below. Run it on a machine where you are comfortable starting a second, temporary PostgreSQL instance.
# 1. fetch the latest base backup into an empty scratch datadir
SCRATCH=/var/lib/wal-g/restore-demo
sudo -u postgres rm -rf "$SCRATCH" && sudo -u postgres mkdir -p "$SCRATCH"
sudo -u postgres wal-g backup-fetch "$SCRATCH" LATEST
# 2. tell the scratch cluster to replay archived WAL to the end, then promote
sudo -u postgres tee "$SCRATCH/postgresql.conf" >/dev/null <<'CONF'
port = 5433
listen_addresses = ''
unix_socket_directories = '/tmp/walg-restore'
archive_mode = off
restore_command = 'wal-g wal-fetch %f %p'
recovery_target_timeline = 'latest'
recovery_target_action = 'promote'
CONF
echo 'local all all trust' | sudo -u postgres tee "$SCRATCH/pg_hba.conf" >/dev/null
sudo -u postgres touch "$SCRATCH/recovery.signal"
sudo mkdir -p /tmp/walg-restore && sudo chown postgres:postgres /tmp/walg-restore
# 3. start the scratch cluster; it recovers, then promotes out of recovery
sudo -u postgres /usr/lib/postgresql/18/bin/pg_ctl -D "$SCRATCH" -w start
sudo -u postgres psql -h /tmp/walg-restore -p 5433 -tAc "SELECT pg_is_in_recovery();"
f means recovery has finished and the restored cluster is writable. Your data — everything committed up to the end of the archived WAL — is now in the scratch cluster.
Point in time recovery to a specific moment. To stop recovery at a chosen time rather than replaying to the end, set a recovery target before starting the scratch cluster:
recovery_target_time = '2026-08-08 05:45:00+00'
recovery_target_action = 'promote'
Restoring in place. To replace the primary cluster instead of restoring into a scratch directory, stop PostgreSQL, empty /var/lib/postgresql/18/main, wal-g backup-fetch into it, add recovery.signal and the restore_command, and start the service. This is destructive to the current cluster, so take a fresh backup-push first.
Step 8: Prove the restore path with the self test
The image ships /usr/local/sbin/walg-selftest.sh. It proves the per machine credentials authenticate, that wrong passwords are rejected, that PostgreSQL is loopback bound, that the store is encrypted (a wrong key fails to decrypt an archived segment), and then performs a complete round trip: it takes a base backup, writes a known row, forces that row into the archived WAL, restores into a scratch datadir by replaying the WAL, and asserts the row came back intact. It is non destructive to your primary cluster.
This is the same check the image passes before it is published.
sudo /usr/local/sbin/walg-selftest.sh
Expected output ends with:
OK: PITR RESTORE PROOF PASSED: row written AFTER the base backup recovered via WAL replay: cloudimg-walg-selftest-20260808055040-8372
SELFTEST_OK: per-VM credentials authenticate, wrong passwords rejected, listener loopback-only, archive encrypted (wrong key rejected), and a real push->list->fetch->PITR round trip recovered a post-backup row.

Step 9: Scheduled backups and retention
Backups are already scheduled by systemd timers, enabled on first boot. Each runs as the postgres user with a low CPU and idle IO priority so scheduled backups never starve the database, and each carries a randomised delay so a fleet of machines does not stampede the store.
| Timer | Schedule | Action |
|---|---|---|
walg-backup.timer |
Daily 01:00 | Full base backup (backup-push) |
walg-delete.timer |
Sunday 03:00 | Retention prune (keep the last 7 full base backups and their WAL) |
systemctl list-timers 'walg-*' --all --no-pager
To change a schedule, edit the timer with sudo systemctl edit walg-backup.timer and set a new OnCalendar= value, then sudo systemctl daemon-reload. WAL archiving itself is continuous through archive_command and is not driven by a timer.
Retention is applied by walg-delete.timer, which runs wal-g delete retain FULL 7 --confirm — it keeps the seven most recent full base backups and the WAL needed to recover from them, and removes older objects. Change the number to suit your retention policy with sudo systemctl edit walg-delete.service.
Step 10: Use an Azure Blob store for off box durability
The shipped default keeps the store on the local dedicated volume, which is perfect for a self contained appliance. For real durability the store should live off the machine. WAL-G supports Azure Blob Storage natively, and switching is a configuration change only — no reinstall.
Edit /etc/wal-g/wal-g.env, comment out WALG_FILE_PREFIX, and add the Azure Blob settings:
# WALG_FILE_PREFIX=/var/lib/wal-g/archive
WALG_AZ_PREFIX=azure://<container-name>/walg
AZURE_STORAGE_ACCOUNT=<storage-account-name>
AZURE_STORAGE_ACCESS_KEY=<storage-account-key>
Keep WALG_LIBSODIUM_KEY and WALG_LIBSODIUM_KEY_TRANSFORM set: with object storage the store leaves your machine, so client side encryption is what keeps the contents private. After changing the store, take a fresh backup-push and confirm it appears in backup-list before relying on it. WAL-G also supports S3 (WALG_S3_PREFIX) and Google Cloud Storage (WALG_GS_PREFIX) the same way.
Step 11: Archive encryption and key rotation
Every object WAL-G writes is encrypted with libsodium using WALG_LIBSODIUM_KEY, generated uniquely for this machine at first boot and recorded in /root/wal-g-credentials.txt.
Rotating the key invalidates every existing backup. WAL-G cannot decrypt objects written under a previous key, so changing
WALG_LIBSODIUM_KEYdoes not re-encrypt the store — it makes everything already in it unreadable. If you must rotate, take a fresh fullbackup-pushafter the change, and keep the old key for as long as you need to restore from the old backups.
Treat the key as you would the backups themselves: store it in your secret manager, and make sure it is recoverable independently of the machine, because a store you cannot decrypt is a store you do not have.
Step 12: Connecting to PostgreSQL
PostgreSQL is bound to loopback. Administrative work is done on the machine as the postgres system user through peer authentication, which needs no password:
sudo -u postgres psql -tAc "SELECT version();"
To connect over TCP with the per machine superuser password from the credentials file:
PGPASSWORD='<the postgres.password value from /root/wal-g-credentials.txt>' \
psql -h 127.0.0.1 -U postgres -c "SELECT current_user;"
To serve clients on your VNet you must make three deliberate changes: set listen_addresses in /etc/postgresql/18/main/conf.d/10-cloudimg-walg.conf, add a pg_hba.conf rule scoped to your subnet CIDR using scram-sha-256, and open TCP 5432 in the Network Security Group for that subnet only. Never open 5432 to the internet.
Troubleshooting
WAL archiving is not reaching the store. Confirm archive_mode is on and archive_command is set, then check the archiver statistics:
sudo -u postgres psql -tAc "SHOW archive_mode; SHOW archive_command;"
sudo -u postgres psql -x -c "SELECT archived_count, failed_count, last_archived_wal, last_failed_wal FROM pg_stat_archiver;"
A rising failed_count with a last_failed_wal means wal-g wal-push is failing — run it by hand to see the error: sudo -u postgres wal-g wal-push /var/lib/postgresql/18/main/pg_wal/<segment>.
First boot did not finish. The first boot unit logs to the journal and to a fixed log file:
systemctl is-enabled wal-g-firstboot.service
sudo journalctl -u wal-g-firstboot.service --no-pager | tail -n 40
The first boot script also writes a copy of its log to /var/lib/cloudimg-adjacent /var/log/cloudimg-firstboot.log; once first boot has run, sudo tail -n 40 /var/log/cloudimg-firstboot.log shows the same detail.
The store volume is missing. The store lives on a dedicated data disk mounted at /var/lib/wal-g. If it is not mounted, first boot refuses to touch the store rather than silently writing backups onto the OS disk:
findmnt /var/lib/wal-g
A backup fails with no space. Check the data volume and either raise the disk size or lower retention:
df -h /var/lib/wal-g
Support
cloudimg images include 24/7 support. Contact support@cloudimg.co.uk with the offer name, the region, and the output of sudo -u postgres wal-g backup-list.
WAL-G is an Apache-2.0 project originated by Citus Data and maintained by the WAL-G community. PostgreSQL is a trademark of the PostgreSQL Community Association of Canada. cloudimg is not affiliated with, endorsed by, or sponsored by the WAL-G project or the PostgreSQL Global Development Group.