← Back to Blog

What to Do With a /48 of IPv6 on Your Server: 8 Tested Recipes

Published · by RS Computers

IPv6 Networking Docker

A /48 of IPv6 on your server is a block of 65,536 separate /64 networks, about 1.2 septillion addresses, that belongs to that one server. You can use it to give every website its own address, send outgoing requests from a chosen address, put Docker containers on real public IPv6 without NAT, and set reverse DNS per address for mail. RS Computers gives every VPS and VDS in Amsterdam and Dublin its own /48 IPv6 prefix. This guide is a set of short recipes for doing those things on Debian or Ubuntu. We ran every command in our lab first, and each recipe shows what we saw.

Key facts, checked on 9 October 2026:

What a /48 is, in plain numbers

An IPv6 address has 128 bits, written as eight groups of four hex digits. The number after the slash says how many bits are fixed. In 2001:db8:abcd::/48 the first three groups are yours and fixed, and the other five groups are yours to fill in. The fourth group alone picks one of 65,536 subnets, and the last four groups pick an address inside that subnet.

PrefixWhat it isHow many
/128One single address1
/64One standard subnet (one LAN, one Docker network, one VPN)18.4 quintillion addresses
/56What many home ISPs hand out256 subnets of /64
/48What an RS Computers server in Amsterdam or Dublin gets65,536 subnets of /64

If the maths feels abstract, let Python count it for you (install it with apt install python3 if your server lacks it):

# as your normal user
python3 - <<EOF
import ipaddress
net = ipaddress.ip_network("2001:db8:abcd::/48")
subnets = list(net.subnets(new_prefix=64))
print(len(subnets), "subnets of /64")
print(subnets[0], subnets[1], subnets[0x10], subnets[-1])
print(f"{net.num_addresses:,} addresses in total")
EOF

Our output, with Python 3.13.5:

65536 subnets of /64
2001:db8:abcd::/64 2001:db8:abcd:1::/64 2001:db8:abcd:10::/64 2001:db8:abcd:ffff::/64
1,208,925,819,614,629,174,706,176 addresses in total

Write an address plan before you start, so an address in a log tells you what it is. The recipes below follow this one. Swap 2001:db8:abcd for the first three groups of your own prefix, which ip -6 addr shows.

RangeUsed forExample
2001:db8:abcd::/64The server itself: main address, websites, outgoing jobs::1 main, ::10 site A, ::11 site B, ::25 mail
2001:db8:abcd:d0c::/64 and d0d::/64Docker networks with public IPv6, one /64 eachd0c::80 web container, d0d::90 Compose app
2001:db8:abcd:20::/64A WireGuard VPN, so each device gets a real IPv6::2 laptop, ::3 phone
2001:db8:abcd:ff00::/56Kept free for laterNothing yet

How we tested this

We ran everything in containers on our Amsterdam test server (Debian 13), on a private bridge. A ULA prefix (a private IPv6 range from RFC 4193, the IPv6 version of 192.168.x.x) played your /48, and a second container played "the internet" so we could see where each request came from. In the outputs below we swapped our lab addresses for the 2001:db8:abcd examples so they match the commands. Versions: Debian 13 with systemd 257 and iproute2 6.15, Ubuntu 24.04 with netplan 1.1.2, nginx 1.26.3, Docker Engine 29.9.0, ufw 0.36.2 and nftables 1.1.3.

Recipe 1: add more addresses without changing your outgoing one

First find your interface name and the address you already have:

# as your normal user
ip -br link
ip -6 addr show dev eth0 scope global

The first command lists interfaces; use the one that is UP and is not lo. It was eth0 on our Amsterdam test server and may be ens3 or similar elsewhere. The second shows your main address and its prefix length, for example 2001:db8:abcd::1/48. Use the same prefix length for the new addresses.

Now add two addresses:

# as your sudo user
sudo ip -6 addr add 2001:db8:abcd::10/48 dev eth0
sudo ip -6 addr add 2001:db8:abcd::11/48 dev eth0
ip -6 -br addr show dev eth0

Both addresses showed up straight away, and a ping from the second machine got an answer from each one within a millisecond.

Then we found the catch most guides skip. We sent a plain request from the server, with no source address chosen, and the other side logged it as coming from ::11, the address we had added last. Linux had picked the newest address as the source for all outgoing IPv6 traffic. If your main address is whitelisted somewhere, or has a reverse DNS name, that change matters.

The fix is to mark extra addresses as "deprecated". They still accept incoming connections and you can still send from them on purpose, but Linux never picks them on its own:

# as your sudo user
sudo ip -6 addr change 2001:db8:abcd::10/48 dev eth0 preferred_lft 0
sudo ip -6 addr change 2001:db8:abcd::11/48 dev eth0 preferred_lft 0
ip -6 addr show dev eth0 scope global

Each extra address now shows deprecated and preferred_lft 0sec. In our test the next plain request left from the main address again, while requests that asked for ::10 or ::11 still used them. Addresses added with ip are gone after a reboot, which is what the next recipe fixes.

Recipe 2: keep the addresses after a reboot

Linux servers use one of three tools for network settings. Check which files exist on yours: /etc/network/interfaces means ifupdown (common on Debian VPS installs), /etc/netplan/ means netplan (Ubuntu), and a file in /etc/systemd/network/ means systemd-networkd. Use only the one your server uses. We rebooted the test machine after each of these, and all three brought the addresses back as deprecated extras.

ifupdown (Debian with /etc/network/interfaces)

Leave your existing IPv6 lines alone and add up lines under the inet6 block:

# as root, in /etc/network/interfaces, under your existing "iface eth0 inet6 static" block
    up ip -6 addr add 2001:db8:abcd::10/48 dev eth0 preferred_lft 0
    up ip -6 addr add 2001:db8:abcd::11/48 dev eth0 preferred_lft 0

netplan (Ubuntu)

Put the extra addresses in their own file, so an update to the main file never removes them. The lifetime: 0 option maps to systemd-networkd's PreferredLifetime=0, which marks the address deprecated, and the netplan docs say it works only with the networkd back end (the default on Ubuntu servers):

# as root
cat > /etc/netplan/60-extra-ipv6.yaml <<EOF
network:
  version: 2
  ethernets:
    eth0:
      addresses:
        - "2001:db8:abcd::10/48":
            lifetime: 0
        - "2001:db8:abcd::11/48":
            lifetime: 0
EOF
chmod 600 /etc/netplan/60-extra-ipv6.yaml
netplan generate && netplan apply

systemd-networkd

A drop-in file adds to the main .network file instead of replacing it. Use the folder that matches your file's name, here eth0.network:

# as root
mkdir -p /etc/systemd/network/eth0.network.d
cat > /etc/systemd/network/eth0.network.d/extra-ipv6.conf <<EOF
[Address]
Address=2001:db8:abcd::10/48
PreferredLifetime=0

[Address]
Address=2001:db8:abcd::11/48
PreferredLifetime=0
EOF
networkctl reload

After networkctl reload the address appeared as deprecated within seconds, and it came back the same way after a reboot. A typo in network files can cut you off from SSH. Make the change with the provider's web console open in another tab, so you can fix it from there.

Recipe 3: one address per website or service

With IPv4 all your sites share one address and the web server sorts them by name. With a /48 each site can have its own address: logs get easier to read, and a service that is not a website (a game server, an API, a mail server) gets a clean address of its own. Here two nginx sites each listen on one address (install nginx first with sudo apt install nginx):

# as root
cat > /etc/nginx/sites-available/site-a <<EOF
server {
    listen [2001:db8:abcd::10]:80;
    root /var/www/site-a;
}
EOF
cat > /etc/nginx/sites-available/site-b <<EOF
server {
    listen [2001:db8:abcd::11]:80;
    root /var/www/site-b;
}
EOF
mkdir -p /var/www/site-a /var/www/site-b
echo "site A" > /var/www/site-a/index.html
echo "site B" > /var/www/site-b/index.html
ln -s /etc/nginx/sites-available/site-a /etc/nginx/sites-enabled/
ln -s /etc/nginx/sites-available/site-b /etc/nginx/sites-enabled/
rm /etc/nginx/sites-enabled/default
nginx -t && systemctl restart nginx
ss -ltn | grep ':80 '

Use restart here, not reload. Our first try used reload, and nginx kept its old [::]:80 socket from the default site; the error log said bind() to [2001:db8:abcd::10]:80 failed (98: Address already in use). After a restart, ss listed one socket per address, and from the other machine curl "http://[2001:db8:abcd::10]/" returned "site A" while ::11 returned "site B". The main address no longer answered on port 80 at all. We rebooted three times, and nginx came up every time.

Then point each site's AAAA record (the IPv6 version of an A record) at its address. For HTTPS, the certificate steps are the same as with one address; our reverse proxy and SSL guide covers Caddy, nginx and Traefik.

Recipe 4: send outgoing requests from a chosen address

Some jobs should leave from their own address: a crawler, a monitoring check, an upload job, an API client that a partner whitelists. curl can choose its source address with --interface:

# as your normal user
curl -s -o /dev/null -w "%{http_code} from %{local_ip}\n" --interface 2001:db8:abcd::11 "http://[2001:db8:ffff::80]/"
ip -6 route get 2001:4860:4860::8888

We got 200 from 2001:db8:abcd::11, and the web server on the other side logged that same address. The second command shows which source Linux would pick for a destination on its own, which is handy after changes like the ones in Recipe 1. Other tools have the same option under another name, such as wget --bind-address or ping -I.

To move all outgoing IPv6 to another address, give the default route a preferred source. Use the gateway your server already has (the first command shows it):

# as your sudo user
ip -6 route show default
sudo ip -6 route replace default via YOUR_GATEWAY dev eth0 src 2001:db8:abcd::10

In our test, curl -6 to Google then left from ::10. Like ip addr, this lasts until the next reboot. For crawlers, our web scraping guide covers rate limits and polite crawling.

Recipe 5: public IPv6 for Docker containers

There are two ways to switch on IPv6 in Docker. They behave very differently.

The simple way: IPv6 with NAT

This gives containers IPv6 for outgoing traffic, hidden behind the server's address, the same way Docker handles IPv4. Docker's IPv6 page names a ULA range from fd00::/8 as a fitting choice here:

# as root
cat > /etc/docker/daemon.json <<EOF
{
  "ipv6": true,
  "fixed-cidr-v6": "fd00:d0c:1::/64"
}
EOF
systemctl restart docker
docker run --rm alpine:3.22 sh -c "ip -6 addr show eth0 scope global; ping -6 -c2 2001:4860:4860::8888"

The container got fd00:d0c:1::2/64 and reached Google over IPv6 with 0% packet loss. Docker added a masquerade rule for that range, which is NAT: the outside world sees your server's address, not the container's.

The /48 way: routed IPv6, no NAT

With thousands of spare /64s, you can hand one to Docker and give each container a real address. Docker calls this the routed gateway mode. Its port publishing docs say that in this mode "only published container ports are accessible", so the firewall stays closed by default:

# as your normal user (a member of the docker group)
docker network create --ipv6 --subnet 2001:db8:abcd:d0c::/64 \
  -o com.docker.network.bridge.gateway_mode_ipv6=routed pub6
docker run -d --name web6 --restart unless-stopped --network pub6 \
  --ip6 2001:db8:abcd:d0c::80 -p 80:80 nginx:1.29-alpine
docker run -d --name hidden6 --restart unless-stopped --network pub6 \
  --ip6 2001:db8:abcd:d0c::81 nginx:1.29-alpine

Our first test from the other machine failed to connect at all, and its neighbour table showed the container's address as FAILED. On a server where the whole /48 sits on the main interface, the network in front of the server asks "who has this address?" on the wire, and nobody answered for the container. That question is NDP (neighbour discovery), the IPv6 version of ARP. The fix is to let the server answer for each container address, which is called proxy NDP:

# as your sudo user
sudo sysctl -w net.ipv6.conf.eth0.proxy_ndp=1
sudo ip -6 neigh add proxy 2001:db8:abcd:d0c::80 dev eth0
sudo ip -6 neigh add proxy 2001:db8:abcd:d0c::81 dev eth0

After that, from the other machine:

The proxy entries disappear on reboot. On systemd-networkd you can make them permanent with a drop-in. In our lab the proxy entries came back after networkctl reload and again after a full reboot, and web6 answered on port 80 as before:

# as root
cat > /etc/systemd/network/eth0.network.d/docker-ndp.conf <<EOF
[Network]
IPv6ProxyNDP=yes
IPv6ProxyNDPAddress=2001:db8:abcd:d0c::80
IPv6ProxyNDPAddress=2001:db8:abcd:d0c::81
EOF
networkctl reload

The same kind of network in Docker Compose. It gets its own /64 (d0d, next to the d0c used above), and the container gets a fixed ipv6_address, because proxy NDP works one address at a time:

# as your normal user, in compose.yaml
services:
  web:
    image: nginx:1.29-alpine
    restart: unless-stopped
    ports:
      - "8080:80"
    networks:
      pub6:
        ipv6_address: 2001:db8:abcd:d0d::90

networks:
  pub6:
    enable_ipv6: true
    driver_opts:
      com.docker.network.bridge.gateway_mode_ipv6: routed
    ipam:
      config:
        - subnet: 2001:db8:abcd:d0d::/64
# as your normal user, in the folder with compose.yaml
docker compose up -d
docker compose ps
sudo ip -6 neigh add proxy 2001:db8:abcd:d0d::90 dev eth0

docker compose ps listed the container as Up with [::]:0->80/tcp, and from the other machine port 80 on ::90 answered 200. Add the address to the docker-ndp.conf drop-in too, so it survives a reboot. Compose v5.6.0 refused our first version with "Pool overlaps with other one on this address space", because we had picked a range inside the network made by hand above. Each Docker network needs its own range, and with a /48 you can give every Compose project its own /64. The cost is tiny: in our lab each nginx container used about 3.3 MiB of RAM, and the whole test machine, with Docker and three web servers, used 134 MB. Installing Docker from Docker's own repository took 11.5 seconds. Our Docker on a VPS guide covers the install.

Recipe 6: reverse DNS for each address

Reverse DNS (a PTR record) maps an address back to a name. Mail servers check it, and it makes traceroutes and logs readable. Each address in your /48 can have its own name. Look one up with dig (package dnsutils on Debian and Ubuntu):

# as your normal user
dig -x 2001:4860:4860::8888 +short
dig +short AAAA dns.google

The first command printed dns.google. and the second gave back 2001:4860:4860::8888 among its answers. That round trip, address to name and name back to the same address, is what receiving mail servers want to see. Google's email sender guidelines require that "the sending IP address must match the IP address of the hostname specified in the Pointer (PTR) record", and they list a specific error for IPv6 senders without one. Any address without a name returns NXDOMAIN.

So if you run mail, give it its own address, for example ::25, with a PTR record, a matching AAAA record and the outgoing source set as in Recipe 4. On RS Computers, set the PTR in the client area, and if the option is not there for an address, send us the address and hostname on Telegram and we set it. Outbound port 25 is closed on new servers and opened on request through Telegram. Our mailcow guide covers the rest of a mail setup.

Recipe 7: a firewall that covers IPv6

IPv6 addresses are public. There is no NAT in front of them to hide a forgotten service, so the firewall has to cover both protocols.

ufw

ufw on Debian 13 ships with IPV6=yes in /etc/default/ufw, so each rule is added twice:

# as your sudo user
grep ^IPV6 /etc/default/ufw
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow proto tcp to 2001:db8:abcd::10 port 8080
sudo ufw --force enable
sudo ufw status numbered

Each plain allow printed two lines, one for IPv4 and one ending in "(v6)", and the status list showed 80/tcp (v6) next to 80/tcp. The rule with to printed only "Rule added (v6)", because it opens a port on that one IPv6 address and nothing else. From the other machine, ping and port 80 worked over IPv6 and a port with no rule timed out.

nftables

One inet table filters IPv4 and IPv6 together. This is /etc/nftables.conf from our test. Keep the ICMPv6 line:

# as root, in /etc/nftables.conf
#!/usr/sbin/nft -f
flush ruleset

table inet filter {
	chain input {
		type filter hook input priority filter; policy drop;
		ct state established,related accept
		ct state invalid drop
		iif "lo" accept
		# IPv6 breaks without these: neighbour discovery, path MTU, ping
		meta l4proto ipv6-icmp accept
		meta l4proto icmp accept
		tcp dport { 22, 80, 443 } accept
		# a port open on one address only
		ip6 daddr 2001:db8:abcd::10 tcp dport 8081 accept
	}
	chain forward {
		type filter hook forward priority filter; policy drop;
	}
	chain output {
		type filter hook output priority filter;
	}
}
# as root
nft -c -f /etc/nftables.conf && nft -f /etc/nftables.conf
systemctl enable --now nftables

Then we deleted the ICMPv6 line to see what happens. IPv6 to the server stopped completely: curl from the other machine failed, and its neighbour table showed the server as FAILED. IPv4 kept working, which is why this mistake is easy to miss. Unlike ICMP in IPv4, ICMPv6 carries neighbour discovery, so dropping it cuts the server off the local network. RFC 4890 lists which ICMPv6 types a firewall must let through. Accepting all ICMPv6, as above, is the simple safe choice.

We tested both firewalls on a machine without Docker. Do not load this nftables file as it is on a Docker host: flush ruleset deletes the rules Docker created, and a forward chain with policy drop blocks the traffic to and from the containers. Docker's page on packet filtering and firewalls explains how its rules fit next to your own.

Recipe 8: check that IPv6 works

# as root
ping -6 -c 3 google.com
curl -6 -s -o /dev/null -w "%{http_code} %{remote_ip}\n" https://www.google.com/
curl -6 -s https://api64.ipify.org; echo
dig +short AAAA example.com

On our test server the ping came back with 0% loss in under a millisecond, curl answered 200 from a Google IPv6 address, api64.ipify.org printed the server's public IPv6 address, and dig listed two IPv6 addresses for example.com. The ipify line is the quickest way to see which address the outside world gets from you after Recipes 1 and 4. To test incoming traffic, connect from another IPv6 machine, for example curl -6 "http://[2001:db8:abcd::10]/" from a second server. On your own computer, test-ipv6.com shows in the browser whether your home connection has IPv6 at all. Many still don't (Ireland stood at 39% in Google's data), and that is why every site should keep working over IPv4 too.

What a /48 does not do

Frequently asked questions

How many IPv6 addresses are in a /48?

A /48 has 2 to the power of 80 addresses, which is 1,208,925,819,614,629,174,706,176. More usefully, it holds 65,536 subnets of /64, the standard size for one network segment.

Is a /48 too much for one server?

It is more than one server needs for addresses alone, but RFC 6177 asks providers to give end sites "in most cases significantly more" than a single /64. The extra /64s are what make Docker, VPN clients and per-site addresses easy without NAT.

Why does my server send traffic from the IPv6 address I added last?

When several addresses fit a destination equally well, Linux picked the one added last in our tests. Add extra addresses with preferred_lft 0, or PreferredLifetime=0 in systemd-networkd and lifetime: 0 in netplan, and your main address stays the outgoing one.

Do Docker containers get public IPv6 automatically?

No. With "ipv6": true Docker uses NAT by default. For public addresses, create a network with a /64 from your prefix and com.docker.network.bridge.gateway_mode_ipv6=routed. When the prefix sits on the main interface, you also need proxy NDP for each container address.

Does ufw block IPv6?

Yes, when enabled. ufw on Debian 13 has IPV6=yes by default and adds a v6 copy of each rule. On older systems check /etc/default/ufw: the comment in that file says that with IPV6=no only IPv6 on loopback is accepted, so your services stop answering over IPv6. Change it to yes, then disable and enable ufw.

Can I set reverse DNS for every IPv6 address?

Yes, each address can have its own PTR record. On RS Computers you set it in the client area, or ask on Telegram with the address and the hostname. Make sure the hostname has a matching AAAA record.

Put your /48 to work

Every VPS and VDS in Amsterdam and Dublin comes with its own IPv4 address and its own /48 of IPv6, on KVM with NVMe storage and unmetered traffic, from the Nano plan up to VDS Large with 10 Gb/s. Servers in Prishtina have their own IPv4 and IPv6 as well. Availability by city is on the plans page. If you want help with an address plan, reverse DNS for a block of addresses, or port 25 for a mail address, write to us on Telegram.

← All articles

Chat on Telegram