All Posts
DevOps
Docker
Nginx
PostgreSQL
Linux
DevOps

Initial Ubuntu 26 Server Setup for Dockerized Deployments

A step-by-step guide to provisioning a bare Ubuntu 26 server — installing Make, Docker, Docker Compose, Nginx, and PostgreSQL, and locking the firewall down to only ports 22, 80, and 443 behind a reverse proxy.

July 3, 20267 min read

Overview

This guide targets a bare Ubuntu 26 server. The goal is to install and enable the following:

  • make
  • Docker
  • Docker Compose
  • Nginx
  • PostgreSQL
  • Only ports 22, 80, and 443 open to the outside world

By the end, the server is ready to run Dockerized projects behind a reverse proxy.

Expected final state

After completing the steps, only these ports should be reachable from outside:

PortServiceState
22/tcpSSHOpen
80/tcpHTTP / NginxOpen
443/tcpHTTPS/ NginxOpen
5432/tcpPostgreSQLlocalhost only

Important: PostgreSQL must not be exposed outside the server. It should only be reachable on 127.0.0.1 or inside the Docker network.

1. Initial Server Update

First, update and upgrade the system packages.

sudo apt update && sudo apt upgrade -y

Then install the base tools we'll need:

sudo apt install -y \
  curl \
  ca-certificates \
  gnupg \
  lsb-release \
  ufw

2. Configure the Firewall (open only 22, 80, 443)

Before enabling the firewall, always allow the SSH port first so you don't lock yourself out of the server.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw default deny routed

Open the required ports:

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

Enable UFW:

sudo ufw enable

Check the firewall status:

sudo ufw status verbose

The expected output should look something like this:

Status: active
Default: deny (incoming), allow (outgoing), deny (routed)

22/tcp    ALLOW IN    Anywhere
80/tcp    ALLOW IN    Anywhere
443/tcp   ALLOW IN    Anywhere

3. Install and Verify Make

Install Make

sudo apt install -y make

Verify the installation

make --version

If the Make version is printed, the installation succeeded.

4. Install and Enable Docker and Docker Compose

Here we install Docker from Docker's official repository to get a recent, standard version.

Remove old or unofficial versions

sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc || true

Add Docker's official GPG key

sudo install -m 0755 -d /etc/apt/keyrings

sudo 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 official repository

sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

Update the package list

sudo apt update

Install Docker and the Docker Compose plugin

sudo apt install -y \
  docker-ce \
  docker-ce-cli \
  containerd.io \
  docker-buildx-plugin \
  docker-compose-plugin

Enable Docker

sudo systemctl enable --now docker

Check Docker status

sudo systemctl status docker --no-pager

Test Docker

docker --version
docker compose version
sudo docker run hello-world

Run Docker without sudo

To let the current user run Docker commands without sudo:

sudo usermod -aG docker $USER

After running the command above, log out of SSH and log back in.

5. Install and Enable Nginx

Install Nginx

sudo apt install -y nginx

Enable Nginx

sudo systemctl enable --now nginx

Check Nginx status

sudo systemctl status nginx --no-pager

Test the Nginx configuration

sudo nginx -t

If the output looks like this, the Nginx configuration is fine:

syntax is ok
test is successful

Verify Nginx is listening on port 80

sudo ss -tulpn | grep nginx

You should see Nginx active on port 80:

0.0.0.0:80
[::]:80

Note: Port 443 is open in the firewall, but until SSL is configured Nginx may not listen on 443. After obtaining an SSL certificate, port 443 will also appear in the output.

6. Install and Enable PostgreSQL

Install PostgreSQL

sudo apt install -y postgresql postgresql-contrib

Enable PostgreSQL

sudo systemctl enable --now postgresql

Check PostgreSQL status

sudo systemctl status postgresql --no-pager

Test PostgreSQL

sudo -u postgres psql -c "SELECT version();"

Restrict PostgreSQL to localhost

For better security, PostgreSQL should not listen on the server's public IP.

First, find the path to the PostgreSQL config file:

PG_CONF=$(sudo -u postgres psql -tAc "SHOW config_file")
echo $PG_CONF

Back up the config file:

sudo cp "$PG_CONF" "$PG_CONF.bak"

Set listen_addresses to localhost:

sudo sed -i -E "s/^#?listen_addresses\s*=.*/listen_addresses = 'localhost'/" "$PG_CONF"

Restart PostgreSQL:

sudo systemctl restart postgresql

Verify the final value:

sudo -u postgres psql -c "SHOW listen_addresses;"

The output should be:

 listen_addresses
------------------
 localhost

Confirm PostgreSQL is local-only

sudo ss -tulpn | grep 5432

The secure output should look like this:

127.0.0.1:5432

If you see 0.0.0.0:5432, PostgreSQL is reachable from outside the server and its configuration needs fixing.

7. Final Check of the Server's Open Ports

To see every port the server is listening on:

sudo ss -tulpn

To see the services exposed on the public IP or on all interfaces:

sudo ss -tulpn | grep -E '0.0.0.0|\[::\]'

Before SSL, the secure output should typically show only these ports:

0.0.0.0:22
0.0.0.0:80
[::]:22
[::]:80

After configuring SSL, port 443 is added:

0.0.0.0:443
[::]:443

8. Final UFW Status Check

sudo ufw status verbose

Expected output:

Status: active
Logging: on
Default: deny (incoming), allow (outgoing), deny (routed)

22/tcp    ALLOW IN    Anywhere
80/tcp    ALLOW IN    Anywhere
443/tcp   ALLOW IN    Anywhere

9. An Important Note About Docker and Ports

Even when UFW is active, Docker can publish ports via ports: in the docker-compose.yml file, bypassing the firewall. For security, only Nginx or the reverse proxy should have a public port.

The correct structure

services:
  nginx:
    image: nginx:latest
    ports:
      - '80:80'
      - '443:443'
    networks:
      - internal

  app:
    image: your-app
    expose:
      - '3000'
    networks:
      - internal

  postgres:
    image: postgres:latest
    expose:
      - '5432'
    networks:
      - internal

networks:
  internal:
    driver: bridge

The wrong structure

This is a mistake because it exposes the backend directly to the internet:

services:
  app:
    image: your-app
    ports:
      - '3000:3000'

It's better to keep the backend behind Nginx and only visible inside the Docker network.

10. Final Checklist

At the end, run these commands:

make --version
docker --version
docker compose version

sudo systemctl status docker --no-pager
sudo systemctl status nginx --no-pager
sudo systemctl status postgresql --no-pager

sudo nginx -t
sudo -u postgres psql -c "SHOW listen_addresses;"
sudo ufw status verbose
sudo ss -tulpn | grep -E '0.0.0.0|\[::\]'

The desired state:

  • Docker is active.
  • Docker Compose is installed.
  • Nginx is active.
  • PostgreSQL is active.
  • PostgreSQL is only on 127.0.0.1:5432.
  • Only ports 22, 80, and 443 are open in the firewall.
  • Before SSL, you may see only 22 and 80 in the ss output.
  • After SSL, port 443 is also listening.

After this stage, it's a good idea to set up SSL for your domain. This is usually done with Certbot:

sudo apt install -y certbot python3-certbot-nginx

Example of obtaining an SSL certificate:

sudo certbot --nginx -d example.com -d www.example.com

After obtaining SSL, check the port status again:

sudo ss -tulpn | grep -E '0.0.0.0|\[::\]'

At this point, you should see 443 alongside 22 and 80.