Setup with Docker
The fastest way to get a real PostgreSQL running is the official postgres Docker image. Docker downloads a ready-made copy of the database, starts it in an isolated container, and tears it down cleanly when you are done — no system-wide install, no leftover services. This lesson gets you from nothing to a running database you can connect to in a couple of commands.
You will need Docker installed and running. Everything below uses plain docker and docker compose commands.
Every transcript here was run on a Mac (Docker Desktop 29.6.1, Docker Compose v5.3.0, aarch64) with the
postgres:18.6image, September 2026. To avoid clashing with other things on this machine the runs used the container namepostgresql-b1-pg, a named volumepostgresql-b1-pgdataand host port55440; the transcripts are shown with the lesson’s names (pg,pgdata,5432) where that is only a name, and the realdocker psoutput is left as it was. Everything was removed afterwards. pgAdmin was not started (its image was not pulled); the compose file is validated withdocker compose configand only thepostgresservice was run.
One command to run Postgres
Section titled “One command to run Postgres”This single command pulls the image if needed and starts a database. It sets the superuser password, publishes the standard port, and stores the data in a named volume so it survives a container restart.
docker run --name pg \ -e POSTGRES_PASSWORD=secret \ -p 5432:5432 \ -v pgdata:/var/lib/postgresql \ -d postgres:18.6A quick tour of the flags:
--name pggives the container a memorable name so later commands can refer to it.-e POSTGRES_PASSWORD=secretsets the password for the defaultpostgresuser. This variable is required; the image refuses to start without it (proved below).-p 5432:5432maps the container’s PostgreSQL port to the same port on your machine, so tools on the host can connect tolocalhost:5432. This publishes on all host interfaces; use-p 127.0.0.1:5432:5432to keep the database reachable from your machine only (that is what the transcripts below did).-v pgdata:/var/lib/postgresqlmounts a named volume one level above the data directory. Since thepostgres:18image the cluster lives in a version-named subdirectory (18/docker) below that mount, so the volume must be mounted at/var/lib/postgresql, not at/var/lib/postgresql/dataas in older tutorials.-d postgres:18.6runs the official image, pinned to the exact release this course was verified on, in the background.
Here is what the run looks like, with the checks the next sections explain:
$ docker run --name pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -v pgdata:/var/lib/postgresql -d postgres:18.6<container-id>
$ docker psNAMES IMAGE STATUS PORTSpostgresql-b1-pg postgres:18.6 Up 6 seconds 127.0.0.1:55440->5432/tcp
$ docker exec pg psql -U postgres -c "show data_directory" -c "select version()" data_directory------------------------------- /var/lib/postgresql/18/docker(1 row)
version-------------------------------------------------------------------------------------------------------------------------- PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit(1 row)
$ docker exec pg psql -U postgres -c "\conninfo" Connection Information Parameter | Value----------------------+--------------------- Database | postgres Client User | postgres Socket Directory | /var/run/postgresql Server Port | 5432 Options | Protocol Version | 3.0 Password Used | false GSSAPI Authenticated | false Backend PID | 90 SSL Connection | false Superuser | on Hot Standby | off(12 rows)The data_directory line is the proof of the volume rule: the cluster is in /var/lib/postgresql/18/docker, under the mount point.
What happens with the old mount path
Section titled “What happens with the old mount path”Tutorials written for PostgreSQL 17 and earlier mount /var/lib/postgresql/data. On the 18 image that container refuses to start:
$ docker run --rm -e POSTGRES_PASSWORD=secret -v pgdata:/var/lib/postgresql/data postgres:18.6Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster" (specifically, using major-version-specific directory names). This better reflects how PostgreSQL itself works, and how upgrades are to be performed.
See also https://github.com/docker-library/postgres/pull/1259
Counter to that, there appears to be PostgreSQL data in: /var/lib/postgresql/data (unused mount/volume)
This is usually the result of upgrading the Docker image without upgrading the underlying database using "pg_upgrade" (which requires both versions).
The suggested container configuration for 18+ is to place a single mount at /var/lib/postgresql which will then place PostgreSQL data in a subdirectory, allowing usage of "pg_upgrade --link" without mount point boundary issues.
See https://github.com/docker-library/postgres/issues/37 for a (long) discussion around this process, and suggestions for how to do so.exit=1The message is long but the fix is one line: mount /var/lib/postgresql (see the docker-library PR referenced in the message and the image documentation).
A docker-compose.yml for repeatable setups
Section titled “A docker-compose.yml for repeatable setups”Typing that command every time gets old, and you often want a GUI alongside the database. A docker-compose.yml captures the whole setup in one file so you can start everything with docker compose up. This one runs PostgreSQL plus pgAdmin, a web GUI you will tour in the next lesson.
services: postgres: image: postgres:18.6 environment: POSTGRES_PASSWORD: secret POSTGRES_DB: appdb ports: - '5432:5432' volumes: - pgdata:/var/lib/postgresql
pgadmin: image: dpage/pgadmin4 environment: PGADMIN_DEFAULT_PASSWORD: secret ports: - '8080:80' depends_on: - postgres
volumes: pgdata:Save it as docker-compose.yml and start everything from that directory:
docker compose up -dPostgreSQL is now on localhost:5432 and pgAdmin on http://localhost:8080. If you would rather use a lighter GUI, swap the pgadmin service for the adminer image, which serves a single-file admin page on its own port. Either way the database service is unchanged. Here is the database service run from that file (only postgres, with the host port swapped to 55440):
$ docker compose config --quiet # the file above parses and validatesexit=0
$ docker compose up -d postgres # pgAdmin image not pulled here; only the database service Network postgresql-b1_default Creating Network postgresql-b1_default Created Volume postgresql-b1_pgdata Creating Volume postgresql-b1_pgdata Created Container postgresql-b1-postgres-1 Creating Container postgresql-b1-postgres-1 Created Container postgresql-b1-postgres-1 Starting Container postgresql-b1-postgres-1 Started
$ docker compose psSERVICE IMAGE STATUS PORTSpostgres postgres:18.6 Up 7 seconds 127.0.0.1:55440->5432/tcp
$ docker compose exec postgres psql -U postgres -d appdb -c "\l appdb" List of databases Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges-------+----------+----------+-----------------+------------+------------+--------+-----------+------------------- appdb | postgres | UTF8 | libc | en_US.utf8 | en_US.utf8 | | |(1 row)
$ docker compose down # data stays in the pgdata volume Container postgresql-b1-postgres-1 Removed Network postgresql-b1_default Removing Volume postgresql-b1_pgdata Removing Volume postgresql-b1_pgdata Removed Network postgresql-b1_default RemovedNow the persistence claim, with the volume this time managed by compose. down removes the container and network but keeps the volume; down -v deletes it:
$ docker compose down Container postgresql-b1-postgres-1 Removed Network postgresql-b1_default Removing Network postgresql-b1_default Removed
$ docker volume ls --filter name=pgdataDRIVER VOLUME NAMElocal postgresql-b1_pgdata
$ docker compose up -d && docker compose exec postgres psql -U postgres -d appdb -c "select * from t" n--- 1(1 row)
$ docker compose down -v # -v also deletes the volume: this one is destructive Volume postgresql-b1_pgdata Removing Volume postgresql-b1_pgdata Removed Network postgresql-b1_default RemovedHow the pieces fit together
Section titled “How the pieces fit together”A container is a running copy of the image, the named volume is where the actual data files live on your machine, and clients connect over the published port. The volume is deliberately separate from the container so the data outlives any single container.
flowchart LR
V[("pgdata named volume")] --> P["postgres container"]
P -->|"port 5432"| H["Host machine"]
H --> CLI["psql or driver"]
H --> GUI["pgAdmin in browser"] Verify and connect
Section titled “Verify and connect”Confirm the container is up, then connect. With psql on your host, point it at the database with a connection string; with no local psql, the image bundles one:
docker pspsql "postgresql://postgres:secret@localhost:5432/postgres"docker exec -it pg psql -U postgresdocker ps shows the postgres:18.6 image with a status of Up and the port mapping. The transcript above (first section) connected through the published port from a second throw-away container, which is exactly the path a host tool takes; \conninfo printed Server Port | 55440 and Password Used | true. Either way you land at the postgres=# prompt, ready to run SQL.
The volume is what carries the data. Same volume, brand-new container, the row is still there; delete the volume and it is gone:
$ psql "postgresql://postgres:secret@localhost:5432/postgres" -c "select current_user, inet_server_port()" # through the published port (55440 here) current_user | inet_server_port--------------+------------------ postgres | 5432(1 row)
Connection Information Parameter | Value----------------------+---------------------- Database | postgres Client User | postgres Host | host.docker.internal Host Address | 192.168.65.254 Server Port | 55440 Options | Protocol Version | 3.0 Password Used | true GSSAPI Authenticated | false Backend PID | 91 SSL Connection | false Superuser | on Hot Standby | off(13 rows)
$ docker exec pg psql -U postgres -c "create table notes (id int, body text)" -c "insert into notes values (1, 'still here')"CREATE TABLEINSERT 0 1
$ docker rm -f pgpostgresql-b1-pg
$ docker run --name pg ... -v pgdata:/var/lib/postgresql -d postgres:18.6 # same volume, brand-new container<container-id>
$ docker exec pg psql -U postgres -c "select * from notes" id | body----+------------ 1 | still here(1 row)
$ docker rm -f pg && docker volume rm pgdata # now delete the volume toopostgresql-b1-pgdata$ (new container, new volume) docker exec pg psql -U postgres -c "select * from notes"ERROR: relation "notes" does not existLINE 1: select * from notes ^Passwords and trust
Section titled “Passwords and trust”POSTGRES_PASSWORD is mandatory unless you explicitly relax authentication. Proved:
$ psql -h <host> -p 55441 -U postgres -w -c "select 1 as no_password_needed" # POSTGRES_HOST_AUTH_METHOD=trust no_password_needed-------------------- 1(1 row)
$ psql -h <host> -p 55442 -U postgres -w -c "select 1" # POSTGRES_PASSWORD only, no password suppliedpsql: error: connection to server at "host.docker.internal" (192.168.65.254), port 55442 failed: fe_sendauth: no password supplied
$ docker run --rm postgres:18.6 # no POSTGRES_PASSWORD at allError: Database is uninitialized and superuser password is not specified. You must specify POSTGRES_PASSWORD to a non-empty value for the superuser. For example, "-e POSTGRES_PASSWORD=password" on "docker run".
You may also use "POSTGRES_HOST_AUTH_METHOD=trust" to allow all connections without a password. This is *not* recommended.
See PostgreSQL documentation about "trust":POSTGRES_HOST_AUTH_METHOD=trust really does let anyone who can reach the port in without a password. Never use it outside a local machine, and never combine it with a port published on all interfaces.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant asked for “a compose file that persists the data” produces something very close to what most blog posts still contain. Read it:
🐛 Spot the bug
# docker-compose.yml (AI comment: "persist the data directory so nothing is lost")services:db: image: postgres:latest environment: POSTGRES_PASSWORD: secret ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data
volumes:pgdata:Show the answer
Two real problems, both proved above. First, the volume is mounted at /var/lib/postgresql/data, so the container exits with status 1 and the Error: in 18+, these Docker images are configured to store database data... message; a file that worked with an image of 17 or older breaks on any 18 image. Second, postgres:latest is unpinned: the same file silently means a different major version on the next docker compose pull, and a PostgreSQL data directory is not compatible across majors without pg_upgrade. The fix mounts /var/lib/postgresql, pins the tag, and binds the port to loopback only:
services: db: image: postgres:18.6 environment: POSTGRES_PASSWORD: secret ports: - "127.0.0.1:5432:5432" volumes: - pgdata:/var/lib/postgresql
volumes: pgdata:Mental Model
Section titled “Mental Model”Image = the recipe (read-only), container = one running copy (disposable), volume = the data (durable), port mapping = the door. Any problem in this lesson is one of those four being wrong: wrong tag, container that died on start, volume at the wrong path, or a door open to the whole network.
Tips / gotchas
Section titled “Tips / gotchas”- The named volume is what makes your data persistent. Remove the container and the data stays; delete the volume and the data is gone for good (both proved above).
5432is PostgreSQL’s default port andpostgresis the default superuser. Tools assume these unless told otherwise.POSTGRES_DBcreates an extra database on first startup (appdbabove). Without it, you still get the built-inpostgresdatabase to work in.POSTGRES_PASSWORD(andPOSTGRES_DB) only take effect when the data directory is empty. Changing them and restarting an existing volume does nothing.POSTGRES_PASSWORDis mandatory for the official image. Use a real secret outside of local experiments, notsecret.- Pin the tag.
postgres:18.6is exact;postgres:18follows minor releases;postgres:latestwill jump majors and break a volume.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
| Run with Docker | Fast, disposable, identical setup on every machine | Container overhead, and slightly different from tuning a real production host |
| Native PostgreSQL install | Closer to a real production environment, no extra layer | Trickier to install/remove, and differs across operating systems |
| Use the default image as-is | Get working immediately, nothing extra to write | No tuning of memory or config for your specific workload |
| Write a custom Dockerfile | Tune config and extensions exactly as needed | More image maintenance and updates to own |
Common Misconceptions
Section titled “Common Misconceptions”- “
docker compose downdeletes my data.” It does not;down -v(ordocker volume rm) does. Both are shown above. - “The volume goes at
/var/lib/postgresql/data.” That was true up to 17. On the 18 image that mount makes the container exit. - “
latestis fine for development.” It is fine until the tag moves to a new major; then the existing volume no longer starts. - “Running as the default superuser locally is harmless.” It hides permission bugs you will hit for real in production. Create a scoped role to test with (next lessons).
- “
POSTGRES_HOST_AUTH_METHOD=trustis just a convenience.” It removes authentication for everyone who can reach the port (proved above).