Skip to content

Hardened I2P Eepsite with i2pd

Created September 12, 2026 • Last updated September 12, 2026 • 7 min read • 260 views

Joshua

HackIndex Creator

This guide hosts a hardened I2P eepsite with i2pd on a current Debian or Ubuntu server. i2pd is the C++ I2P router: one binary, two config files, a systemd service, and no Java. You get a router that does nothing except publish your site, with the web console, the proxies, and every client API switched off.

Why i2pd for a hardened I2P eepsite

The Java router from geti2p.net ships a router console and a bundle of web apps, from a torrent client to webmail. That is a desktop package. A server needs the opposite: a small daemon with a flat config you can read in one screen. i2pd is that daemon, and it lives in the distribution repositories. Never run both routers on one host.

Three things that differ from a plain client

I2P is peer to peer. Your router performs far better when other routers can reach it, so you open one inbound port for TCP and UDP. A firewalled router still works, but slower and with fewer peers.

Your router relays transit traffic for others by default. That costs bandwidth but gives you cover traffic and helps the network. Turn it off only when bandwidth is tight.

The network takes time. The first start reseeds and builds tunnels, so expect five to ten minutes before the site answers. The router keeps improving over the following hours. Nothing is broken during that window. Reseed errors in the log are normal as well: the router tries several reseed servers and moves on when one fails, which can add a few minutes.

Install i2pd

The package sits in the distribution repositories with only boost, OpenSSL, zlib, and a small UPnP library as dependencies. Install it from there. If you need a newer release than the distribution ships, PurpleI2P maintains an Ubuntu PPA at ppa:purplei2p/i2pd.

user@host ~ $ sudo apt update
user@host ~ $ sudo apt install -y i2pd nginx nftables

Minimal i2pd.conf

Replace the shipped /etc/i2pd/i2pd.conf with this. Every service a server does not need is set to false. Pick your own high port and keep it, since the firewall below must match. Comments use a hash, not a semicolon.

ipv4 = true
ipv6 = false
port = 12345
bandwidth = O
notransit = false
log = file
logfile = /var/log/i2pd/i2pd.log
loglevel = error

[http]
enabled = false

[httpproxy]
enabled = false

[socksproxy]
enabled = false

[sam]
enabled = false

[bob]
enabled = false

[i2cp]
enabled = false

[i2pcontrol]
enabled = false

[upnp]
enabled = false

[reseed]
verify = true

The [http] section is the web console, which you do not need on a server. The proxies, SAM, BOB, I2CP, and I2PControl are client interfaces. The bandwidth letter sets the share your router offers: O is 256 KBps, P is 2048, X is unlimited, or give a number in KBps. The reseed verify flag enforces signature checks on the bootstrap bundle. Keep loglevel at error. At warn, the first hour fills the log with profiling and session messages that look alarming but are normal while the router integrates. One error at the very first start, about a RouterInfo file it cannot open, is harmless too: the file does not exist yet.

The server tunnel

The site itself is one block in /etc/i2pd/tunnels.conf. It points the tunnel at a web server on loopback. The keys path is relative to /var/lib/i2pd/. When the file does not exist, i2pd creates it on start, and that key pair is your address.

[site]
type = http
host = 127.0.0.1
port = 8080
keys = site.dat

Nginx on loopback

Serve the site from loopback only, so nothing listens on the public interface. Keep the server block a catch-all and use relative URLs throughout the site, because the Host header that arrives through the tunnel is never a public name. Hide the version banner, disable directory listing and absolute redirects, and send errors to a page that names nothing. Turn the ETag off as well: it encodes file modification time and size, so a clearnet copy of the same files would carry identical ETags and link the two sites. Save this as /etc/nginx/sites-available/eepsite.

server {
    listen 127.0.0.1:8080;
    server_name _;

    root /var/www/eepsite;
    index index.html;

    server_tokens off;
    absolute_redirect off;
    port_in_redirect off;
    autoindex off;
    access_log off;
    etag off;

    add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
    add_header Referrer-Policy "no-referrer" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;

    location ~ /\. {
        deny all;
    }

    error_page 403 404 /error.html;
    error_page 500 502 503 504 /error.html;
    location = /error.html {
        internal;
    }
}
user@host ~ $ sudo mkdir -p /var/www/eepsite
user@host ~ $ echo "Service unavailable." | sudo tee /var/www/eepsite/error.html
user@host ~ $ sudo chown -R www-data:www-data /var/www/eepsite
user@host ~ $ sudo ln -s /etc/nginx/sites-available/eepsite /etc/nginx/sites-enabled/eepsite
user@host ~ $ sudo rm -f /etc/nginx/sites-enabled/default
user@host ~ $ sudo nginx -t
user@host ~ $ sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Firewall

Set a default drop on input and output. Inbound, allow return traffic, loopback, SSH, and the i2pd port on both TCP and UDP. Outbound, allow only the i2pd user to open new connections, plus NTP for the clock. The web server inherits the drop, so a compromised site cannot reach out and expose the host. Write this to /etc/nftables.conf and apply it.

#!/usr/sbin/nft -f
flush ruleset

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;
        ct state established,related accept
        ct state invalid drop
        iif "lo" accept
        tcp dport 22 accept
        # i2pd transport port. Must match port in i2pd.conf.
        tcp dport 12345 accept
        udp dport 12345 accept
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
    }

    chain output {
        type filter hook output priority 0; policy drop;
        ct state established,related accept
        oif "lo" accept
        # Only the i2pd daemon may open new outbound connections.
        meta skuid "i2pd" accept
        udp dport 123 accept
    }
}
user@host ~ $ sudo nft -f /etc/nftables.conf
user@host ~ $ sudo systemctl enable --now nftables
user@host ~ $ sudo systemctl enable --now i2pd
user@host ~ $ sudo systemctl restart i2pd

Read the b32 address

With the console off, read the address from the key file with keyinfo from i2pd-tools. Build the tools once. The same build gives you vain for a vanity I2P address. Clone with submodules, since the repository pulls in the i2pd library.

user@host ~ $ sudo apt install -y git build-essential libboost-chrono-dev libboost-date-time-dev libboost-filesystem-dev libboost-program-options-dev libssl-dev zlib1g-dev
user@host ~ $ git clone --recursive https://github.com/PurpleI2P/i2pd-tools
user@host ~ $ cd i2pd-tools
user@host ~ $ make
user@host ~ $ sudo ./keyinfo /var/lib/i2pd/site.dat
c22brvyamaj2wxuz5z5zmxt4kwo7wh6yd3pzxigm5bpwfp6jbuza.b32.i2p
Explain command
--recursive Fetches the git submodules, which the build needs for the i2pd library.

If you would rather not compile, set enabled = true and address = 127.0.0.1 under [http], restart i2pd, read the address from the I2P tunnels page on port 7070 over an SSH tunnel, then switch the console off again.

Test from a client

Use a separate machine with i2pd where the HTTP proxy is enabled on its default port 4444. Give the router a few minutes after first start, then fetch the site through the proxy. Replace $I2P_ADDRESS with the b32 hostname from keyinfo. Note that no Server header comes back: the i2pd http tunnel strips it, so the web server never announces its name through the tunnel.

user@host ~ $ curl -I -x http://127.0.0.1:4444 http://$I2P_ADDRESS/
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 11
Connection: close
Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Accept-Ranges: bytes
Proxy-Connection: close
Explain command
-x http://127.0.0.1:4444 Sends the request through the local i2pd HTTP proxy.

On the server, confirm nothing web related listens on a public address. Only SSH and the i2pd transport port should appear on a routable interface.

user@host ~ $ sudo ss -tulpn
Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
tcp   LISTEN 0      511    127.0.0.1:8080      0.0.0.0:*         users:(("nginx",pid=901,fd=6))
tcp   LISTEN 0      128    0.0.0.0:22          0.0.0.0:*         users:(("sshd",pid=744,fd=3))
tcp   LISTEN 0      1024   0.0.0.0:12345       0.0.0.0:*         users:(("i2pd",pid=1130,fd=12))
udp   UNCONN 0      0      0.0.0.0:12345       0.0.0.0:*         users:(("i2pd",pid=1130,fd=13))

Operations

Back up site.dat offline and test a restore on a throwaway host. To check reachability, enable the console on loopback as described above and read Network status on its front page. Firewalled means the inbound port is not reachable, which points at the firewall or the provider. Raise the bandwidth letter once you see how much transit the host carries, or set notransit to true if the traffic is a problem.

To serve more than one site, add a second block to tunnels.conf with its own keys file and its own loopback port. Each block is a separate address on the same router.

The generated address is random. To pick the first characters yourself, grind a vanity I2P address with vain and drop it in as site.dat.

References