{"id":3623,"date":"2026-07-16T03:51:59","date_gmt":"2026-07-16T03:51:59","guid":{"rendered":"https:\/\/tucumandevelopers.com\/index.php\/2026\/07\/16\/how-to-set-up-postgresql-with-docker-on-ubuntu\/"},"modified":"2026-07-16T03:51:59","modified_gmt":"2026-07-16T03:51:59","slug":"how-to-set-up-postgresql-with-docker-on-ubuntu","status":"publish","type":"post","link":"https:\/\/tucumandevelopers.com\/index.php\/2026\/07\/16\/how-to-set-up-postgresql-with-docker-on-ubuntu\/","title":{"rendered":"How to Set Up PostgreSQL with Docker on Ubuntu"},"content":{"rendered":"<div>\n<div>\n<div data-article-id=\"4128969\" id=\"article-body\">\n<blockquote>\n<h2> <a name=\"tldr\" href=\"#tldr\"> <\/a> TL;DR <\/h2>\n<p>This post walks through running PostgreSQL 18 on a fresh Ubuntu server using Docker<br \/> Compose, with a named volume for data persistence, secrets in a <code>.env<\/code> file, a<br \/> healthcheck, and the port bound to localhost only (with an SSH tunnel for remote<br \/> access). By the end you&#8217;ll have a Postgres instance that survives container<br \/> restarts, isn&#8217;t exposed to the open internet, and is easy to back up.<\/p>\n<\/blockquote>\n<p>You need a Postgres instance on a server for an app, a side project, or a homelab<br \/> setup. Docker Compose is the fastest way to get a reproducible setup \u2014 no manual<br \/> <code>apt install postgresql<\/code>, no fighting with system package versions, and the whole<br \/> config lives in one file you can commit to git (minus secrets).<\/p>\n<h2> <a name=\"environment\" href=\"#environment\"> <\/a> Environment <\/h2>\n<div>\n<pre><code>- 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) <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<h2> <a name=\"approach-key-decisions\" href=\"#approach-key-decisions\"> <\/a> Approach \/ Key Decisions <\/h2>\n<p>A few choices worth calling out before the config, since they&#8217;re the parts people<br \/> usually get wrong on the first try:<\/p>\n<ul>\n<li> <strong>Pin the image tag<\/strong> (<code>postgres:18.4<\/code>), not <code>latest<\/code> \u2014 reproducibility matters more than convenience once you have real data in the volume.<\/li>\n<li> <strong>Named volume, not bind mount<\/strong>, for the data directory \u2014 avoids permission headaches between the container&#8217;s internal user and the host filesystem.<\/li>\n<li> <strong>Secrets in <code>.env<\/code><\/strong>, never hardcoded in <code>docker-compose.yml<\/code> \u2014 so the compose file itself is safe to commit to git.<\/li>\n<li> <strong>Bind the port to <code>127.0.0.1<\/code> only<\/strong> \u2014 Postgres has no business listening on <code>0.0.0.0<\/code> on a public server. If you need remote access, use an SSH tunnel or a VPN, not an open port.<\/li>\n<li> <strong>Add a healthcheck<\/strong> \u2014 so anything depending on Postgres (an app container, a CI job) can wait for &#8220;actually ready to accept queries,&#8221; not just &#8220;container started.&#8221;<\/li>\n<\/ul>\n<h2> <a name=\"solution-implementation\" href=\"#solution-implementation\"> <\/a> Solution \/ Implementation <\/h2>\n<h3> <a name=\"1-install-docker-engine-compose-plugin\" href=\"#1-install-docker-engine-compose-plugin\"> <\/a> 1. Install Docker Engine + Compose plugin <\/h3>\n<div>\n<pre><code><span># refresh package index and install packages needed to add a new apt repo over HTTPS<\/span> <span>sudo <\/span>apt update <span>sudo <\/span>apt <span>install<\/span> <span>-y<\/span> ca-certificates curl gnupg <span># create the directory that will hold Docker's GPG key<\/span> <span>sudo install<\/span> <span>-m<\/span> 0755 <span>-d<\/span> \/etc\/apt\/keyrings <span># download Docker's official GPG key and make it readable by apt<\/span> curl <span>-fsSL<\/span> https:\/\/download.docker.com\/linux\/ubuntu\/gpg <span>-o<\/span> \/etc\/apt\/keyrings\/docker.asc <span>sudo chmod <\/span>a+r \/etc\/apt\/keyrings\/docker.asc <span># add Docker's apt repo, matching your CPU arch and Ubuntu codename automatically<\/span> <span>echo<\/span> <span>\\<\/span> <span>\"deb [arch=<\/span><span>$(<\/span>dpkg <span>--print-architecture<\/span><span>)<\/span><span> signed-by=\/etc\/apt\/keyrings\/docker.asc] https:\/\/download.docker.com\/linux\/ubuntu <\/span><span>\\<\/span><span> <\/span><span>$(<\/span><span>.<\/span> \/etc\/os-release <span>&amp;&amp;<\/span> <span>echo<\/span> <span>\"<\/span><span>$VERSION_CODENAME<\/span><span>\"<\/span><span>)<\/span><span> stable\"<\/span> | <span>\\<\/span> <span>sudo tee<\/span> \/etc\/apt\/sources.list.d\/docker.list <span>&gt;<\/span> \/dev\/null <span># pick up the new repo, then install Docker Engine + the Compose v2 plugin<\/span> <span>sudo <\/span>apt update <span>sudo <\/span>apt <span>install<\/span> <span>-y<\/span> docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Let your user run docker without <code>sudo<\/code> (log out\/in, or <code>newgrp docker<\/code>, after this): <\/p>\n<div>\n<pre><code><span># add your user to the docker group so you don't need sudo for every docker command<\/span> <span>sudo <\/span>usermod <span>-aG<\/span> docker <span>$USER<\/span> <span># refresh your shell's group membership without needing to fully log out<\/span> newgrp docker <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Verify: <\/p>\n<div>\n<pre><code>docker <span>--version<\/span> <span># confirm Docker Engine installed correctly<\/span> docker compose version <span># confirm the Compose v2 plugin is available<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<h3> <a name=\"2-set-up-the-project-directory\" href=\"#2-set-up-the-project-directory\"> <\/a> 2. Set up the project directory <\/h3>\n<div>\n<pre><code><span># init-scripts\/ will hold optional setup SQL\/shell scripts (explained below)<\/span> <span>mkdir<\/span> <span>-p<\/span> ~\/postgres-docker\/init-scripts <span>cd<\/span> ~\/postgres-docker <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p><code>init-scripts\/<\/code> is optional \u2014 anything in there (<code>.sql<\/code> or <code>.sh<\/code> files) runs once,<br \/> automatically, the first time the container initializes an empty data directory.<br \/> Useful for creating extra databases, extensions, or seed data.<\/p>\n<h3> <a name=\"3-create-the-raw-env-endraw-file\" href=\"#3-create-the-raw-env-endraw-file\"> <\/a> 3. Create the <code>.env<\/code> file <\/h3>\n<div>\n<pre><code><span># writes a .env file that docker-compose.yml will read via ${VAR} substitution.<\/span> <span># NOTE: docker compose's .env parser doesn't strip inline \"# comments\" after a<\/span> <span># value, so don't add trailing comments on these lines \u2014 they'd become part of<\/span> <span># the value itself. Generate a real password with: openssl rand -base64 24<\/span> <span>cat<\/span> <span>&gt;<\/span> .env <span>&lt;&lt;<\/span> <span>'<\/span><span>EOF<\/span><span>' POSTGRES_USER=appuser POSTGRES_PASSWORD=change_this_to_a_long_random_password POSTGRES_DB=appdb POSTGRES_PORT=5432 <\/span><span>EOF <\/span><span># keep secrets out of git<\/span> <span>echo<\/span> <span>\".env\"<\/span> <span>&gt;&gt;<\/span> .gitignore <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<h3> <a name=\"4-create-raw-dockercomposeyml-endraw-\" href=\"#4-create-raw-dockercomposeyml-endraw-\"> <\/a> 4. Create <code>docker-compose.yml<\/code> <\/h3>\n<div>\n<pre><code><span>services<\/span><span>:<\/span> <span>postgres<\/span><span>:<\/span> <span>image<\/span><span>:<\/span> <span>postgres:18.4<\/span> <span># pinned version, not `latest` \u2014 see \"Key Decisions\" above<\/span> <span>container_name<\/span><span>:<\/span> <span>postgres_db<\/span> <span>restart<\/span><span>:<\/span> <span>unless-stopped<\/span> <span># auto-restart on crash or host reboot (as long as Docker itself starts on boot)<\/span> <span>environment<\/span><span>:<\/span> <span># values are pulled from .env in the same directory via ${VAR} substitution<\/span> <span>POSTGRES_USER<\/span><span>:<\/span> <span>${POSTGRES_USER}<\/span> <span>POSTGRES_PASSWORD<\/span><span>:<\/span> <span>${POSTGRES_PASSWORD}<\/span> <span>POSTGRES_DB<\/span><span>:<\/span> <span>${POSTGRES_DB}<\/span> <span>ports<\/span><span>:<\/span> <span># bind to localhost only \u2014 NOT \"5432:5432\", which would expose it on all interfaces<\/span> <span>-<\/span> <span>\"<\/span><span>127.0.0.1:${POSTGRES_PORT}:5432\"<\/span> <span>volumes<\/span><span>:<\/span> <span>-<\/span> <span>pgdata:\/var\/lib\/postgresql\/data<\/span> <span># named volume \u2014 persists data across container recreation<\/span> <span>-<\/span> <span>.\/init-scripts:\/docker-entrypoint-initdb.d<\/span> <span># optional: runs once against an empty data dir<\/span> <span>healthcheck<\/span><span>:<\/span> <span># checks Postgres can actually accept queries, not just that the process is running<\/span> <span>test<\/span><span>:<\/span> <span>[<\/span><span>\"<\/span><span>CMD-SHELL\"<\/span><span>,<\/span> <span>\"<\/span><span>pg_isready<\/span><span> <\/span><span>-U<\/span><span> <\/span><span>${POSTGRES_USER}<\/span><span> <\/span><span>-d<\/span><span> <\/span><span>${POSTGRES_DB}\"<\/span><span>]<\/span> <span>interval<\/span><span>:<\/span> <span>10s<\/span> <span># how often to run the check<\/span> <span>timeout<\/span><span>:<\/span> <span>5s<\/span> <span># how long to wait for a response before counting it as failed<\/span> <span>retries<\/span><span>:<\/span> <span>5<\/span> <span># consecutive failures before marking the container \"unhealthy\"<\/span> <span>volumes<\/span><span>:<\/span> <span>pgdata<\/span><span>:<\/span> <span># declares the named volume used above; Docker manages the storage location<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<h3> <a name=\"5-bring-it-up\" href=\"#5-bring-it-up\"> <\/a> 5. Bring it up <\/h3>\n<div>\n<pre><code>docker compose up <span>-d<\/span> <span># start the container in the background, reading .env + docker-compose.yml<\/span> docker compose ps <span># check it's running (and, once healthchecks pass, \"healthy\")<\/span> docker compose logs <span>-f<\/span> postgres <span># tail the logs; Ctrl+C to stop watching (container keeps running)<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Wait for <code>database system is ready to accept connections<\/code> in the logs, or check<br \/> the healthcheck status in <code>docker compose ps<\/code> (it should show <code>healthy<\/code>).<\/p>\n<h2> <a name=\"verification\" href=\"#verification\"> <\/a> Verification <\/h2>\n<p><strong>From inside the container:<\/strong> <\/p>\n<div>\n<pre><code><span># -it gives you an interactive terminal attached to the psql session<\/span> docker <span>exec<\/span> <span>-it<\/span> postgres_db psql <span>-U<\/span> appuser <span>-d<\/span> appdb <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Once in <code>psql<\/code>, sanity-check the connection: <\/p>\n<div>\n<pre><code><span>\\<\/span><span>conninfo<\/span> <span>-- shows which user\/db\/host\/port you're connected as<\/span> <span>\\<\/span><span>l<\/span> <span>-- lists all databases visible to this user<\/span> <span>\\<\/span><span>q<\/span> <span>-- exit psql<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p><strong>Healthcheck directly:<\/strong> <\/p>\n<div>\n<pre><code><span># same check docker-compose.yml's healthcheck runs internally \u2014 useful for manual debugging<\/span> docker <span>exec <\/span>postgres_db pg_isready <span>-U<\/span> appuser <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p><strong>From the host<\/strong> (if you have <code>psql<\/code> installed locally \u2014 <code>sudo apt install postgresql-client<\/code>): <\/p>\n<div>\n<pre><code><span># works because the port is bound to 127.0.0.1, i.e. reachable from the host itself<\/span> psql <span>-h<\/span> 127.0.0.1 <span>-p<\/span> 5432 <span>-U<\/span> appuser <span>-d<\/span> appdb <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p><strong>From a remote machine<\/strong>, since the port is bound to localhost only, tunnel in first: <\/p>\n<div>\n<pre><code><span># forwards local port 5432 through SSH to the server's 127.0.0.1:5432<\/span> ssh <span>-L<\/span> 5432:127.0.0.1:5432 youruser@your-server-ip <span># then, in a separate terminal on your local machine, connect as if Postgres were local:<\/span> psql <span>-h<\/span> 127.0.0.1 <span>-p<\/span> 5432 <span>-U<\/span> appuser <span>-d<\/span> appdb <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<h2> <a name=\"lessons-learned-gotchas\" href=\"#lessons-learned-gotchas\"> <\/a> Lessons Learned \/ Gotchas <\/h2>\n<ul>\n<li> <strong><code>docker compose down<\/code> vs <code>docker compose down -v<\/code><\/strong> \u2014 the first keeps your named volume (data survives); the second deletes it too. Easy to fumble this when you&#8217;re tearing down containers to &#8220;start fresh.&#8221;<\/li>\n<li> <strong>Changing <code>POSTGRES_USER<\/code>\/<code>POSTGRES_PASSWORD<\/code>\/<code>POSTGRES_DB<\/code> after the volume already has data does nothing<\/strong> \u2014 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.<\/li>\n<li> <strong>Never expose 5432 to <code>0.0.0.0<\/code> on a public server.<\/strong> Postgres auth is not designed to be your only line of defense against internet-wide scanning and brute-force attempts.<\/li>\n<li> <strong>Backups are still your job.<\/strong> A container restart is not a backup strategy. <\/li>\n<\/ul>\n<div>\n<pre><code> <span># dumps the database to a plain SQL file on the host, named with today's date<\/span> docker <span>exec <\/span>postgres_db pg_dump <span>-U<\/span> appuser appdb <span>&gt;<\/span> backup_<span>$(<\/span><span>date<\/span> +%F<span>)<\/span>.sql <span># restore into a fresh instance (-i keeps stdin open so the file content reaches psql)<\/span> <span>cat <\/span>backup_2026-07-16.sql | docker <span>exec<\/span> <span>-i<\/span> postgres_db psql <span>-U<\/span> appuser <span>-d<\/span> appdb <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<ul>\n<li> <strong><code>restart: unless-stopped<\/code><\/strong> means Postgres comes back up after a server reboot \u2014 but only if the Docker daemon itself is set to start on boot (<code>sudo systemctl enable docker<\/code>, usually on by default after the apt install).<\/li>\n<\/ul>\n<h2> <a name=\"references\" href=\"#references\"> <\/a> References <\/h2>\n<ul>\n<li><a href=\"https:\/\/hub.docker.com\/_\/postgres\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL Docker Hub image docs<\/a><\/li>\n<li><a href=\"https:\/\/docs.docker.com\/reference\/compose-file\/\" target=\"_blank\" rel=\"noopener noreferrer\">Docker Compose file reference<\/a><\/li>\n<li><a href=\"https:\/\/docs.docker.com\/engine\/install\/ubuntu\/\" target=\"_blank\" rel=\"noopener noreferrer\">Docker Engine install docs for Ubuntu<\/a><\/li>\n<\/ul><\/div>\n<\/div>\n<\/div>\n<\/div>\n<p>Fuente: <a href=\"https:\/\/dev.to\/edwinsoji\/how-to-set-up-postgresql-with-docker-on-ubuntu-1p90\">Art\u00edculo original<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>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&#8217;ll have a Postgres instance that survives container [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":3622,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2}},"categories":[41],"tags":[],"class_list":["post-3623","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devto"],"jetpack_publicize_connections":[],"_links":{"self":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/3623","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/comments?post=3623"}],"version-history":[{"count":0,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/3623\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media\/3622"}],"wp:attachment":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media?parent=3623"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/categories?post=3623"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/tags?post=3623"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}