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.
Overview
This guide targets a bare Ubuntu 26 server. The goal is to install and enable the following:
makeDockerDocker ComposeNginxPostgreSQL- Only ports
22,80, and443open 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:
| Port | Service | State |
|---|---|---|
22/tcp | SSH | Open |
80/tcp | HTTP / Nginx | Open |
443/tcp | HTTPS/ Nginx | Open |
5432/tcp | PostgreSQL | localhost 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, and443are open in the firewall. - Before SSL, you may see only
22and80in thessoutput. - After SSL, port
443is also listening.
11. Recommended Next Step
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.