Deployment guide

WAL-G

Overview

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. It compresses and encrypts everything it writes, and it restores a cluster to any point in time.

WAL-G is a command-line binary, and this SecureAnyCloud image is more than a bare binary drop. It is a complete PostgreSQL backup and point-in-time recovery host on Ubuntu 24.04 on Azure. WAL-G is already configured against a pinned PostgreSQL server, with continuous WAL archiving 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.

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. It writes the per-machine WAL-G configuration, takes an initial base backup and confirms that the backup is listed. The machine is protecting data before you log in.

Nothing exposed. PostgreSQL is configured with listen_addresses = 'localhost', so it binds 127.0.0.1 only. This image ships no network-reachable database port, and the only inbound port is SSH 22. pg_hba.conf is restrictive and contains no trust rule anywhere: peer for the local postgres superuser, and scram-sha-256 for every other path.

No baked-in 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, because every build-time backup and archived WAL segment is destroyed before capture.

What you get:

  • PostgreSQL 18.4 (pinned, from the official PostgreSQL Global Development Group (PGDG) apt repository, signing key verified by full fingerprint at build time) and WAL-G 3.0.8 (official upstream release, SHA-256 verified before install)
  • Continuous WAL archiving through 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 you can prove point-in-time recovery rather than trust it
  • 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

You need an active Azure subscription, an SSH public key, and a VNet with a subnet in the target region. Subscribe to the WAL-G listing on Azure Marketplace.

Configure a Network Security Group rule that allows TCP 22 for administration.

Recommended virtual machine size: Standard_B2s (2 vCPU, 4 GB RAM) is sufficient for the backup host itself. To protect large or busy databases, choose a size with more CPU for compression and encryption, such as Standard_D2s_v5 or larger, and a larger data disk.

1

Deploy from the Azure Portal

Search Marketplace for WAL-G, select the SecureAnyCloud publisher, and choose Create. Configure the Network Security Group to allow TCP 22 for administration only.

Do not open TCP 5432. This image binds PostgreSQL to loopback, so nothing listens on that port for you to reach.

The image declares a dedicated data disk for the backup store. Azure provisions the disk and mounts it at /var/lib/wal-g automatically. Size the disk for your retention policy before you deploy.

2

Deploy from the Azure CLI

RG="database-prod"; LOCATION="eastus"; VM_NAME="walg1" IMAGE_URN="sac1647283583153:wal-g:default:latest" SSH_KEY="$(cat ~/.ssh/id_rsa.pub)" az group create --name "$RG" --location "$LOCATION"
# Accept the Marketplace terms once per subscription (required before the first CLI deploy)
az vm image terms accept --urn sac1647283583153:wal-g:default:latest
az vm create \
  --resource-group "$RG" --name "$VM_NAME" \
  --image "$IMAGE_URN" \
  --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
3

First boot

On first boot, the image checks that the store volume is mounted and generates the per-machine PostgreSQL password and WAL-G libsodium key. It writes the per-machine /etc/wal-g/wal-g.env, starts PostgreSQL, takes an initial base backup, confirms that the backup 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 the key, your backups cannot be decrypted, and therefore cannot be restored.

4

Confirm the service and the security posture

Confirm that PostgreSQL is running and that WAL-G and PostgreSQL report the pinned versions. The listener set shows that the database is bound to loopback and that SSH is the only externally reachable port.

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 on loopback. Only port 22 is bound to a routable address.

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 that there are no gaps in the timeline.

sudo -u postgres wal-g wal-verify integrity
6

The backup workflow

The WAL-G workflow is a handful of commands. PostgreSQL archives WAL continuously through archive_command, and 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 that the timeline is intact. A gap is caught before you need the backup, not during a restore.

sudo -u postgres wal-g wal-verify integrity
7

Restore and point-in-time recovery

WAL-G restores by fetching a base backup into an empty data directory and then replaying archived WAL on top of it. This is how point-in-time recovery works: the base backup 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. The shipped self test does exactly this (Step 8). The outline below shows the same procedure. 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 instead of replaying to the end, set a recovery target before you start 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 and empty /var/lib/postgresql/18/main. Run wal-g backup-fetch into it, add recovery.signal and the restore_command, and start the service. This destroys the current cluster, so take a fresh backup-push first.

8

Prove the restore path with the self test

The image ships /usr/local/sbin/walg-selftest.sh. The script proves that the per-machine credentials authenticate, that wrong passwords are rejected, that PostgreSQL is bound to loopback, and that the store is encrypted (a wrong key fails to decrypt an archived segment).

It then performs a complete round trip. It takes a base backup, writes a known row and forces that row into the archived WAL. It restores into a scratch datadir by replaying the WAL and asserts that the row came back intact. The self test does not touch your primary cluster. The image passes the same check 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: sac-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.
9

Scheduled backups and retention

systemd timers, enabled on first boot, already schedule your backups. Each timer runs as the postgres user with a low CPU and idle I/O priority, so scheduled backups do not starve the database. Each also carries a randomized delay, so a fleet of machines does not hit the store at the same moment.

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 run sudo systemctl daemon-reload. WAL archiving itself is continuous through archive_command and is not driven by a timer.

walg-delete.timer applies retention by running wal-g delete retain FULL 7 --confirm. This keeps the seven most recent full base backups and the WAL needed to recover from them, and removes older objects. To change the number to suit your retention policy, run sudo systemctl edit walg-delete.service.

10

Use an Azure Blob store for off box durability

By default, the store lives on the local dedicated volume, which suits a self-contained appliance. For real durability, keep the store off the machine. WAL-G supports Azure Blob Storage natively, and switching is a configuration change only, with 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 keeps the contents private.

After you change the store, take a fresh backup-push and confirm that it appears in backup-list before you rely on it. WAL-G also supports S3 (WALG_S3_PREFIX) and Google Cloud Storage (WALG_GS_PREFIX) in the same way.

11

Archive encryption and key rotation

WAL-G encrypts every object it writes with libsodium, using WALG_LIBSODIUM_KEY. The key is generated uniquely for this machine at first boot and recorded in /root/wal-g-credentials.txt.

Rotating the key makes every existing backup unreadable. WAL-G cannot decrypt objects written under a previous key. Changing WALG_LIBSODIUM_KEY does not re-encrypt the store, so everything already in it becomes unreadable. If you must rotate, take a fresh full backup-push after the change. 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 keep it recoverable independently of the machine. A store you cannot decrypt is a store you do not have.

12

Connecting to PostgreSQL

PostgreSQL is bound to loopback. Do administrative work 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, use 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, make three deliberate changes. Set listen_addresses in /etc/postgresql/18/main/conf.d/10-sac-walg.conf. Add a pg_hba.conf rule scoped to your subnet CIDR using scram-sha-256. 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 that 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 the /var/lib/sac-adjacent /var/log/sac/sac-firstboot.log. Once first boot has run, sudo tail -n 40 /var/log/sac/sac-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 the disk is not mounted, first boot refuses to touch the store instead of 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

SecureAnyCloud images include 24/7 support. Contact sales@secureanycloud.com 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. SecureAnyCloud is not affiliated with, endorsed by, or sponsored by the WAL-G project or the PostgreSQL Global Development Group.

Deploy on Azure

Launch WAL-G PostgreSQL Backup & PITR Host on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure