TL;DR
This post walks through running PostgreSQL 18 on a fresh Ubuntu server using Docker
Compose, with a named volume for data persistence, secrets in a.envfile, a
healthcheck, and the port bound to localhost only (with an SSH tunnel for remote
access). By the end you’ll have a Postgres instance that survives container
restarts, isn’t exposed to the open internet, and is easy to back up.
You need a Postgres instance on a server for an app, a side project, or a homelab
setup. Docker Compose is the fastest way to get a reproducible setup — no manual
apt install postgresql, no fighting with system package versions, and the whole
config lives in one file you can commit to git (minus secrets).
Environment
- Ubuntu 22.04 or 24.04 LTS - Docker Engine (installed via Docker's official apt repo) - Docker Compose v2 (the `docker compose` plugin, not the old standalone docker-compose) - PostgreSQL 18 (official image from Docker Hub)
Approach / Key Decisions
A few choices worth calling out before the config, since they’re the parts people
usually get wrong on the first try:
- Pin the image tag (
postgres:18.4), notlatest— reproducibility matters more than convenience once you have real data in the volume. - Named volume, not bind mount, for the data directory — avoids permission headaches between the container’s internal user and the host filesystem.
- Secrets in
.env, never hardcoded indocker-compose.yml— so the compose file itself is safe to commit to git. - Bind the port to
127.0.0.1only — Postgres has no business listening on0.0.0.0on a public server. If you need remote access, use an SSH tunnel or a VPN, not an open port. - Add a healthcheck — so anything depending on Postgres (an app container, a CI job) can wait for “actually ready to accept queries,” not just “container started.”
Solution / Implementation
1. Install Docker Engine + Compose plugin
# refresh package index and install packages needed to add a new apt repo over HTTPS sudo apt update sudo apt install -y ca-certificates curl gnupg # create the directory that will hold Docker's GPG key sudo install -m 0755 -d /etc/apt/keyrings # download Docker's official GPG key and make it readable by apt curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc # add Docker's apt repo, matching your CPU arch and Ubuntu codename automatically echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # pick up the new repo, then install Docker Engine + the Compose v2 plugin sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Let your user run docker without sudo (log out/in, or newgrp docker, after this):
# add your user to the docker group so you don't need sudo for every docker command sudo usermod -aG docker $USER # refresh your shell's group membership without needing to fully log out newgrp docker
Verify:
docker --version # confirm Docker Engine installed correctly docker compose version # confirm the Compose v2 plugin is available
2. Set up the project directory
# init-scripts/ will hold optional setup SQL/shell scripts (explained below) mkdir -p ~/postgres-docker/init-scripts cd ~/postgres-docker
init-scripts/ is optional — anything in there (.sql or .sh files) runs once,
automatically, the first time the container initializes an empty data directory.
Useful for creating extra databases, extensions, or seed data.
3. Create the .env file
# writes a .env file that docker-compose.yml will read via ${VAR} substitution. # NOTE: docker compose's .env parser doesn't strip inline "# comments" after a # value, so don't add trailing comments on these lines — they'd become part of # the value itself. Generate a real password with: openssl rand -base64 24 cat > .env << 'EOF' POSTGRES_USER=appuser POSTGRES_PASSWORD=change_this_to_a_long_random_password POSTGRES_DB=appdb POSTGRES_PORT=5432 EOF # keep secrets out of git echo ".env" >> .gitignore
4. Create docker-compose.yml
services: postgres: image: postgres:18.4 # pinned version, not `latest` — see "Key Decisions" above container_name: postgres_db restart: unless-stopped # auto-restart on crash or host reboot (as long as Docker itself starts on boot) environment: # values are pulled from .env in the same directory via ${VAR} substitution POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} ports: # bind to localhost only — NOT "5432:5432", which would expose it on all interfaces - "127.0.0.1:${POSTGRES_PORT}:5432" volumes: - pgdata:/var/lib/postgresql/data # named volume — persists data across container recreation - ./init-scripts:/docker-entrypoint-initdb.d # optional: runs once against an empty data dir healthcheck: # checks Postgres can actually accept queries, not just that the process is running test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s # how often to run the check timeout: 5s # how long to wait for a response before counting it as failed retries: 5 # consecutive failures before marking the container "unhealthy" volumes: pgdata: # declares the named volume used above; Docker manages the storage location
5. Bring it up
docker compose up -d # start the container in the background, reading .env + docker-compose.yml docker compose ps # check it's running (and, once healthchecks pass, "healthy") docker compose logs -f postgres # tail the logs; Ctrl+C to stop watching (container keeps running)
Wait for database system is ready to accept connections in the logs, or check
the healthcheck status in docker compose ps (it should show healthy).
Verification
From inside the container:
# -it gives you an interactive terminal attached to the psql session docker exec -it postgres_db psql -U appuser -d appdb
Once in psql, sanity-check the connection:
\conninfo -- shows which user/db/host/port you're connected as \l -- lists all databases visible to this user \q -- exit psql
Healthcheck directly:
# same check docker-compose.yml's healthcheck runs internally — useful for manual debugging docker exec postgres_db pg_isready -U appuser
From the host (if you have psql installed locally — sudo apt install postgresql-client):
# works because the port is bound to 127.0.0.1, i.e. reachable from the host itself psql -h 127.0.0.1 -p 5432 -U appuser -d appdb
From a remote machine, since the port is bound to localhost only, tunnel in first:
# forwards local port 5432 through SSH to the server's 127.0.0.1:5432 ssh -L 5432:127.0.0.1:5432 youruser@your-server-ip # then, in a separate terminal on your local machine, connect as if Postgres were local: psql -h 127.0.0.1 -p 5432 -U appuser -d appdb
Lessons Learned / Gotchas
-
docker compose downvsdocker compose down -v— the first keeps your named volume (data survives); the second deletes it too. Easy to fumble this when you’re tearing down containers to “start fresh.” - Changing
POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DBafter the volume already has data does nothing — those env vars only take effect on first init of an empty data directory. To change them later, update the values with SQL directly, or wipe the volume and start over. - Never expose 5432 to
0.0.0.0on a public server. Postgres auth is not designed to be your only line of defense against internet-wide scanning and brute-force attempts. - Backups are still your job. A container restart is not a backup strategy.
# dumps the database to a plain SQL file on the host, named with today's date docker exec postgres_db pg_dump -U appuser appdb > backup_$(date +%F).sql # restore into a fresh instance (-i keeps stdin open so the file content reaches psql) cat backup_2026-07-16.sql | docker exec -i postgres_db psql -U appuser -d appdb
-
restart: unless-stoppedmeans Postgres comes back up after a server reboot — but only if the Docker daemon itself is set to start on boot (sudo systemctl enable docker, usually on by default after the apt install).