DEV Community

Edwin Sanjo Soji
Edwin Sanjo Soji

Posted on

How to Set Up PostgreSQL with Docker on Ubuntu

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 .env file, 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)
Enter fullscreen mode Exit fullscreen mode

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), not latest — 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 in docker-compose.yml — so the compose file itself is safe to commit to git.
  • Bind the port to 127.0.0.1 only — Postgres has no business listening on 0.0.0.0 on 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Verify:

docker --version         # confirm Docker Engine installed correctly
docker compose version   # confirm the Compose v2 plugin is available
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Healthcheck directly:

# same check docker-compose.yml's healthcheck runs internally — useful for manual debugging
docker exec postgres_db pg_isready -U appuser
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Lessons Learned / Gotchas

  • docker compose down vs docker 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_DB after 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.0 on 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
Enter fullscreen mode Exit fullscreen mode
  • restart: unless-stopped means 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).

References

Top comments (0)