Configure UFW Firewall on Ubuntu for Web Servers
UFW, short for Uncomplicated Firewall, is a simple firewall management tool for Ubuntu. It is a good fit for web servers because the usual rule set is easy to reason about: allow web traffic, restrict SSH, deny unexpected inbound connections, and keep outbound traffic working.
This guide walks through a deployment script that configures UFW for a typical Ubuntu web server.
The example IP addresses in this article use documentation-safe ranges such as 203.0.113.10, 198.51.100.20, and 192.0.2.15. Replace them with your own trusted SSH source IPs before running the commands on a real server.
What the Script Does
The Configure-UFWFirewall.sh script:
- Installs
ufwif it is missing. - Sets the default incoming policy to deny.
- Sets the default outgoing policy to allow.
- Opens HTTP on
80/tcp. - Opens HTTPS on
443/tcp. - Removes broad SSH rules such as
OpenSSH,ssh, and unrestricted22/tcp. - Adds SSH allow rules only for configured source IPs.
- Enables UFW with
ufw --force enable. - Prints the final verbose UFW status.
- Writes a timestamped log under
/opt/DevOps/Logs.
That pattern is useful for public web servers because ports 80 and 443 need to be reachable by everyone, but SSH should normally be limited to trusted administrator locations.
Important SSH Warning
Be careful with firewall changes over SSH.
If you enable UFW while your current client IP is not in the SSH allowlist, you can lock yourself out of the server. Before applying the script, confirm the public IP address you are connecting from and include it in the allowlist.
If possible, keep a cloud console, hypervisor console, or recovery session open while changing firewall rules.
Preview the Changes First
Start with a dry run:
sudo bash /opt/DevOps/Scripts/ServerDeployment/Configure-UFWFirewall.sh --dry-run
The dry run shows the commands the script would execute without changing the active firewall.
Run with a Custom SSH Allowlist
Use --ssh-ips when you want to replace the default SSH allowlist:
sudo bash /opt/DevOps/Scripts/ServerDeployment/Configure-UFWFirewall.sh \ --ssh-ips "203.0.113.10,198.51.100.20"
In this example:
203.0.113.10represents one trusted administrator IP.198.51.100.20represents another trusted administrator IP.
Use your actual trusted IPs instead.
You can also add individual IPs with repeated --ssh-ip options:
sudo bash /opt/DevOps/Scripts/ServerDeployment/Configure-UFWFirewall.sh \ --ssh-ip 203.0.113.10 \ --ssh-ip 198.51.100.20
Run the Default Configuration
If the script defaults have already been reviewed and adjusted for your environment, run:
sudo bash /opt/DevOps/Scripts/ServerDeployment/Configure-UFWFirewall.sh
For a real server, do not rely on sample IPs from an article. Put your own trusted SSH source IPs into the command or into the script configuration.
Use a Non-Standard SSH Port
If SSH listens on a port other than 22, pass it explicitly:
sudo bash /opt/DevOps/Scripts/ServerDeployment/Configure-UFWFirewall.sh \ --ssh-port 2222 \ --ssh-ips "203.0.113.10,198.51.100.20"
Only do this if your SSH daemon is actually listening on that port.
Check the UFW Service
After applying the firewall rules, check the service:
sudo systemctl status ufw
You want UFW to be installed, enabled, and active.
Check the Firewall Rules
Show the verbose status:
sudo ufw status verbose
You should expect a shape like this:
Status: active Logging: on Default: deny (incoming), allow (outgoing), disabled (routed) To Action From -- ------ ---- 80/tcp ALLOW IN Anywhere 443/tcp ALLOW IN Anywhere 22/tcp ALLOW IN 203.0.113.10 22/tcp ALLOW IN 198.51.100.20
The important part is that SSH is limited to the trusted source IPs instead of being open to Anywhere.
Show Numbered Rules
Numbered rules are helpful when you need to delete or inspect a specific rule:
sudo ufw status numbered
Example output:
Status: active
To Action From
-- ------ ----
[ 1] 80/tcp ALLOW IN Anywhere
[ 2] 443/tcp ALLOW IN Anywhere
[ 3] 22/tcp ALLOW IN 203.0.113.10
[ 4] 22/tcp ALLOW IN 198.51.100.20
If you see an unrestricted SSH rule such as 22/tcp ALLOW IN Anywhere, remove it only after confirming your source-specific SSH rules are correct.
Confirm Listening Services
UFW controls access, but it is still useful to confirm which services are listening:
sudo ss -tulpn | grep -E ':22|:80|:443'
Typical output might show:
tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1234,fd=3))
tcp LISTEN 0 4096 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=2345,fd=6))
tcp LISTEN 0 4096 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=2345,fd=7))
If Nginx is not listening on 80 or 443, check the Nginx service and configuration separately.
Full Script
Here is the full Bash script used for this UFW firewall workflow. The SSH allowlist IPs have been replaced with documentation-safe examples; replace them with your own trusted IPs before running it:
#!/usr/bin/env bash
__show_script_usage() {
cat <<'__SCRIPT_USAGE__'
# Configure-UFWFirewall.sh
Installs/enables UFW, opens HTTP/HTTPS publicly, and restricts SSH to a
configured allowlist.
## Example Usage
Apply the default policy:
```bash
sudo bash /opt/DevOps/Scripts/ServerDeployment/Configure-UFWFirewall.sh
```
Preview changes without applying them:
```bash
sudo bash /opt/DevOps/Scripts/ServerDeployment/Configure-UFWFirewall.sh --dry-run
```
Use a custom SSH allowlist:
```bash
sudo bash /opt/DevOps/Scripts/ServerDeployment/Configure-UFWFirewall.sh \
--ssh-ips "203.0.113.10,198.51.100.20,192.0.2.15"
```
Add one or more SSH IPs to the defaults:
```bash
sudo bash /opt/DevOps/Scripts/ServerDeployment/Configure-UFWFirewall.sh \
--ssh-ip 203.0.113.10 \
--ssh-ip 198.51.100.20
```
## Defaults
- Public inbound: `80/tcp`, `443/tcp`
- SSH port: `22/tcp`
- SSH allowlist: `203.0.113.10`, `198.51.100.20`
- Incoming default policy: deny
- Outgoing default policy: allow
## Notes
The script removes broad SSH allow rules such as `OpenSSH`, `ssh`, and
`22/tcp`, then adds source-specific SSH allow rules. Run from a console or from
an SSH session whose client IP is included in the allowlist.
__SCRIPT_USAGE__
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
case "${1:-}" in
-h|--help|--usage)
__show_script_usage
exit 0
;;
esac
fi
set -euo pipefail
SCRIPT_LOG_ROOT="${SCRIPT_LOG_ROOT:-/opt/DevOps/Logs}"
LOG_FILE="${SCRIPT_LOG_ROOT}/Configure-UFWFirewall.$(date '+%Y.%m.%d.%H%M%S').log"
DEFAULT_SSH_ALLOWED_IPS=(
"203.0.113.10"
"198.51.100.20"
)
SSH_ALLOWED_IPS=("${DEFAULT_SSH_ALLOWED_IPS[@]}")
SSH_PORT="${SSH_PORT:-22}"
DRY_RUN="false"
INSTALL_UFW="true"
while [ "$#" -gt 0 ]; do
case "$1" in
--ssh-ip)
if [ "$#" -lt 2 ] || [[ "${2:-}" == --* ]]; then
echo "ERROR: --ssh-ip requires an IP address." >&2
exit 1
fi
SSH_ALLOWED_IPS+=("${2:-}")
shift 2
;;
--ssh-ips)
if [ "$#" -lt 2 ] || [[ "${2:-}" == --* ]]; then
echo "ERROR: --ssh-ips requires a comma- or space-separated list." >&2
exit 1
fi
IFS=', ' read -r -a SSH_ALLOWED_IPS <<< "${2:-}"
shift 2
;;
--ssh-port)
if [ "$#" -lt 2 ] || [[ "${2:-}" == --* ]]; then
echo "ERROR: --ssh-port requires a port number." >&2
exit 1
fi
SSH_PORT="${2:-}"
shift 2
;;
--no-install)
INSTALL_UFW="false"
shift
;;
--dry-run)
DRY_RUN="true"
shift
;;
*)
echo "ERROR: Unknown argument: $1" >&2
exit 1
;;
esac
done
mkdir -p "${SCRIPT_LOG_ROOT}"
exec > >(tee -a "${LOG_FILE}") 2>&1
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}
fail() {
log "ERROR: $*"
exit 1
}
run() {
if [ "${DRY_RUN}" = "true" ]; then
log "DRY RUN: $*"
return 0
fi
"$@"
}
require_command() {
local command_name="$1"
if ! command -v "${command_name}" >/dev/null 2>&1; then
fail "Required command not found: ${command_name}"
fi
}
validate_port() {
local port="$1"
if ! [[ "${port}" =~ ^[0-9]+$ ]] || [ "${port}" -lt 1 ] || [ "${port}" -gt 65535 ]; then
fail "Invalid SSH port: ${port}"
fi
}
validate_ip_or_cidr() {
local value="$1"
if ! [[ "${value}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$ ]]; then
fail "Invalid IPv4/CIDR value: ${value}"
fi
}
dedupe_ips() {
local ip
local deduped=()
local seen=" "
for ip in "${SSH_ALLOWED_IPS[@]}"; do
[ -n "${ip}" ] || continue
if [[ "${seen}" != *" ${ip} "* ]]; then
deduped+=("${ip}")
seen+="${ip} "
fi
done
SSH_ALLOWED_IPS=("${deduped[@]}")
}
delete_broad_ssh_rules() {
local broad_rules=(
"allow OpenSSH"
"limit OpenSSH"
"allow ssh"
"limit ssh"
"allow ${SSH_PORT}/tcp"
"limit ${SSH_PORT}/tcp"
)
local rule
log "Removing broad SSH allow/limit rules if present."
for rule in "${broad_rules[@]}"; do
if [ "${DRY_RUN}" = "true" ]; then
log "DRY RUN: ufw delete ${rule} || true"
else
# These delete commands are idempotent enough for UFW automation:
# missing rules return non-zero and are ignored.
ufw delete ${rule} >/dev/null 2>&1 || true
fi
done
}
apply_firewall_rules() {
local ip
log "Applying UFW defaults."
run ufw default deny incoming
run ufw default allow outgoing
log "Allowing HTTP and HTTPS publicly."
run ufw allow 80/tcp
run ufw allow 443/tcp
delete_broad_ssh_rules
log "Allowing SSH only from configured source IPs."
for ip in "${SSH_ALLOWED_IPS[@]}"; do
log "Allow SSH from ${ip} to port ${SSH_PORT}/tcp"
run ufw allow from "${ip}" to any port "${SSH_PORT}" proto tcp
done
log "Enabling UFW."
run ufw --force enable
}
show_status() {
if [ "${DRY_RUN}" = "true" ]; then
log "DRY RUN: ufw status verbose"
return 0
fi
ufw status verbose
}
if [ "${EUID}" -ne 0 ] && [ "${DRY_RUN}" != "true" ]; then
fail "Run with sudo, or use --dry-run for preview."
fi
validate_port "${SSH_PORT}"
dedupe_ips
if [ "${#SSH_ALLOWED_IPS[@]}" -eq 0 ]; then
fail "SSH allowlist cannot be empty."
fi
for ip in "${SSH_ALLOWED_IPS[@]}"; do
validate_ip_or_cidr "${ip}"
done
log "Log file: ${LOG_FILE}"
log "SSH port: ${SSH_PORT}/tcp"
log "SSH allowed IPs: ${SSH_ALLOWED_IPS[*]}"
log "Public ports: 80/tcp 443/tcp"
log "Dry run: ${DRY_RUN}"
if ! command -v ufw >/dev/null 2>&1; then
if [ "${INSTALL_UFW}" != "true" ]; then
fail "ufw is not installed and --no-install was passed."
fi
log "Installing UFW."
if [ "${DRY_RUN}" = "true" ]; then
log "DRY RUN: apt update"
log "DRY RUN: apt install -y ufw"
else
require_command apt
apt update
apt install -y ufw
fi
fi
if [ "${DRY_RUN}" != "true" ]; then
require_command ufw
fi
apply_firewall_rules
show_status
log "UFW firewall configuration complete."
Troubleshooting
If UFW is inactive, check:
sudo systemctl status ufw sudo ufw status verbose
If SSH access is failing from a trusted location, confirm the IP in the rule matches the public IP your connection is coming from:
curl -4 ifconfig.me
Then add the missing source IP from a console session:
sudo ufw allow from 192.0.2.15 to any port 22 proto tcp sudo ufw status numbered
If you need to temporarily disable UFW from a recovery console:
sudo ufw disable
Use that only as a recovery step. The better fix is to add the correct source-specific SSH rule and re-enable UFW.
Quick Reference
sudo bash /opt/DevOps/Scripts/ServerDeployment/Configure-UFWFirewall.sh --dry-run sudo bash /opt/DevOps/Scripts/ServerDeployment/Configure-UFWFirewall.sh \ --ssh-ips "203.0.113.10,198.51.100.20" sudo systemctl status ufw sudo ufw status verbose sudo ufw status numbered sudo ss -tulpn | grep -E ':22|:80|:443'
A firewall script should be boring, predictable, and easy to verify. For a web server, that means public HTTP and HTTPS, restricted SSH, denied unexpected inbound traffic, and a simple set of commands to prove the final state.