How I Locked Down My SSH Server with Traefik and a Dynamic IP Filter
I'm always a little paranoid about leaving my SSH port open to the internet. It feels like an open invitation for bots to come knocking. I wanted to find a way to lock it down so that only I could access it, even when my IP address changes because I'm traveling or my ISP decides to give me a new one.

After some tinkering, I came up with a pretty neat solution using Traefik as a reverse proxy. Here's how I did it.
Step 1: Making sshd a Local-Only Affair
First things first, I didn't want sshd to be accessible from the outside world at all. So, I edited my /etc/ssh/sshd_config
file and changed ListenAddress
to:
ListenAddress 127.0.0.1
This tells sshd to only listen for connections coming from the server itself. A quick restart of the sshd service, and it was firewalled off from the internet.
Be careful! If you misconfigure this, you could lock yourself out of your server. Always have a backup access method, like a console or a recovery mode, before making changes to your SSH configuration.
sudo systemctl restart sshd
Step 2: Setting Up Traefik with Docker Compose
Next, I set up Traefik using Docker Compose. The idea is to have Traefik listen on a public port and then forward the traffic to my local-only sshd.
Here's my docker-compose.yml
:
version: '3'
services:
traefik:
image: "traefik:v2.9"
container_name: "traefik"
ports:
- "2222:2222" # My new public SSH port
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik.yml:/traefik.yml:ro"
- "./dynamic_conf.yml:/dynamic_conf.yml:ro"
restart: unless-stopped
This sets up Traefik and exposes port 2222 to the world. It also mounts a couple of configuration files, which is where the magic happens.
My static config in traefik.yml
is super simple:
entryPoints:
ssh:
address: ":2222"
providers:
file:
filename: "/dynamic_conf.yml"
watch: true
This just tells Traefik to create an entry point called ssh
on port 2222 and to watch dynamic_conf.yml
for any changes.
Step 3: The Dynamic Config and IP Whitelisting
This is the core of the setup. In dynamic_conf.yml
, I set up a middleware that only allows traffic from my IP address.
tcp:
middlewares:
ip-whitelist:
ipWhiteList:
sourceRange:
- "1.2.3.4" # My IP goes here
routers:
ssh-router:
entryPoints:
- "ssh"
rule: "HostSNI(`*`)"
service: "sshd-service"
middlewares:
- "ip-whitelist"
tls: {}
services:
sshd-service:
loadBalancer:
servers:
- address: "172.17.0.1:22" # The Docker host's IP
This config does a few things:
- It creates a middleware called
ip-whitelist
that specifies which IPs are allowed. - It sets up a router to listen on the
ssh
entry point and applies theip-whitelist
middleware. - It defines a service that points to my sshd, which is now only accessible from inside Docker at
172.17.0.1:22
.
Step 4: Automating IP Updates with a Web Service
Manually updating the IP address is a pain. To solve this, I built a small web service that exposes a secure API endpoint to update the IP address in the Traefik configuration file automatically.
To keep things organized, let's create a new directory called ip-api
for our new service. Inside this directory, we'll place the following files.
The Dockerfile
First, the Dockerfile
to containerize our Node.js application. This sets up a Node.js 20 environment, installs dependencies, copies the source code, and runs the server as a non-root node
user for better security.
FROM node:20-alpine
WORKDIR /app
# Install dependencies
COPY package.json ./
RUN npm install --omit=dev
# Copy application source
COPY server.js ./
ENV NODE_ENV=production \
PORT=8080
EXPOSE 8080
USER node
CMD ["node", "server.js"]
The package.json
Next, a package.json
to define our project and its dependencies. Our service relies on express
for the web server and js-yaml
to handle the YAML configuration file.
{
"name": "ip-api",
"version": "1.0.0",
"description": "API to update home IP in Traefik middleware",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"js-yaml": "^4.1.0"
}
}
The server.js
This is the core of our service. It's an Express.js application that listens for a POST
request on /update-ip
.
- Authentication: It requires a secure token passed in the
X-Auth-Token
header. It uses a constant-time comparison to prevent timing attacks. - IP Validation: It validates and normalizes the provided IP address, automatically handling both plain IPs and CIDR ranges.
- YAML Manipulation: It safely reads the existing Traefik dynamic configuration, updates the IP in our
ip-whitelist
middleware, and writes the file back, preserving any other settings.
I've adapted the code to perfectly match the ip-whitelist
middleware we configured in Step 3 and the traefik:v2.9
image used in the blog post.
const express = require('express');
const fsp = require('fs/promises');
const path = require('path');
const yaml = require('js-yaml');
const crypto = require('crypto');
const net = require('net');
const PORT = process.env.PORT ? Number(process.env.PORT) : 8080;
const IP_FILE_PATH = process.env.IP_FILE_PATH || '/data/dynamic_conf.yml';
const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
if (!AUTH_TOKEN || AUTH_TOKEN.length < 32) {
console.error('ERROR: AUTH_TOKEN must be provided and at least 32 characters long.');
process.exit(1);
}
const app = express();
app.use(express.json());
// Simple request logger
app.use((req, res, next) => {
if (req.path !== '/healthz') {
console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
}
next();
});
// Constant-time token validation using hashing to avoid length leaks
function tokenValidFromHeader(req) {
const headerToken = req.header('X-Auth-Token') || '';
try {
const a = crypto.createHash('sha256').update(headerToken, 'utf8').digest();
const b = crypto.createHash('sha256').update(AUTH_TOKEN, 'utf8').digest();
return crypto.timingSafeEqual(a, b);
} catch (e) {
return false;
}
}
// Auth middleware except for health endpoint
app.use((req, res, next) => {
if (req.path === '/healthz') return next();
if (!tokenValidFromHeader(req)) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
});
function normalizeIpCidr(input) {
if (!input || typeof input !== 'string') {
throw new Error('IP value must be a non-empty string');
}
const trimmed = input.trim();
const hasSlash = trimmed.includes('/');
let ip = trimmed;
let cidrStr;
if (hasSlash) {
const parts = trimmed.split('/');
if (parts.length !== 2) throw new Error('Invalid IP/CIDR format');
ip = parts[0];
cidrStr = parts[1];
}
const ipVersion = net.isIP(ip);
if (ipVersion === 0) {
throw new Error('Invalid IP address');
}
let cidr;
if (cidrStr === undefined || cidrStr === '') {
cidr = ipVersion === 6 ? 128 : 32;
} else {
const n = Number(cidrStr);
if (!Number.isInteger(n)) throw new Error('CIDR must be an integer');
if (ipVersion === 4 && (n < 0 || n > 32)) throw new Error('CIDR for IPv4 must be between 0 and 32');
if (ipVersion === 6 && (n < 0 || n > 128)) throw new Error('CIDR for IPv6 must be between 0 and 128');
cidr = n;
}
return { ip, cidr, ipCidr: `${ip}/${cidr}` };
}
function ensureStructure(obj) {
if (!obj || typeof obj !== 'object') obj = {};
if (!obj.tcp) obj.tcp = {};
if (!obj.tcp.middlewares) obj.tcp.middlewares = {};
if (!obj.tcp.middlewares['ip-whitelist']) obj.tcp.middlewares['ip-whitelist'] = {};
if (!obj.tcp.middlewares['ip-whitelist'].ipWhiteList) obj.tcp.middlewares['ip-whitelist'].ipWhiteList = {};
if (!Array.isArray(obj.tcp.middlewares['ip-whitelist'].ipWhiteList.sourceRange)) {
obj.tcp.middlewares['ip-whitelist'].ipWhiteList.sourceRange = [];
}
return obj;
}
async function readYamlConfig(filePath) {
try {
const content = await fsp.readFile(filePath, 'utf8');
const data = yaml.load(content) || {};
return ensureStructure(data);
} catch (err) {
if (err.code === 'ENOENT') {
return ensureStructure({});
}
throw err;
}
}
async function writeYamlConfig(filePath, data) {
const dir = path.dirname(filePath);
await fsp.mkdir(dir, { recursive: true });
const yamlStr = yaml.dump(data, { lineWidth: 120 });
await fsp.writeFile(filePath, yamlStr, 'utf8');
}
function getCurrentSourceRanges(data) {
const tcpRanges = data?.tcp?.middlewares?.['ip-whitelist']?.ipWhiteList?.sourceRange || [];
return { tcpRanges };
}
function updateRangesIfChanged(data, ipCidr) {
const { tcpRanges } = getCurrentSourceRanges(data);
const currentTcp = tcpRanges[0];
const needsUpdate = currentTcp !== ipCidr || tcpRanges.length !== 1;
if (needsUpdate) {
data.tcp.middlewares['ip-whitelist'].ipWhiteList.sourceRange = [ipCidr];
}
return { updated: needsUpdate };
}
app.get('/healthz', (req, res) => {
res.status(200).send('ok');
});
app.post('/update-ip', async (req, res) => {
try {
const provided = req.body?.ip || req.body?.ipCidr;
if (!provided) {
return res.status(400).json({ error: 'Missing ip (optionally with CIDR) in request body' });
}
const { ip, cidr, ipCidr } = normalizeIpCidr(provided);
let data = await readYamlConfig(IP_FILE_PATH);
const { updated } = updateRangesIfChanged(data, ipCidr);
if (updated) {
await writeYamlConfig(IP_FILE_PATH, data);
}
data = await readYamlConfig(IP_FILE_PATH);
const { tcpRanges } = getCurrentSourceRanges(data);
return res.json({
updated,
ip,
cidr,
ipCidr,
tcp: tcpRanges,
filePath: IP_FILE_PATH
});
} catch (err) {
console.error('Error in /update-ip:', err);
return res.status(400).json({ error: err.message || 'Invalid request' });
}
});
app.listen(PORT, () => {
console.log(`ip-api listening on port ${PORT}`);
console.log(`Using file: ${IP_FILE_PATH}`);
});
Updating Traefik's Configuration
To expose our new API service via Traefik, we need to enable an HTTPS entrypoint. We'll update traefik.yml
to add web
and websecure
entrypoints and configure a Let's Encrypt certificate resolver. Make sure to replace your-email@example.com
with your actual email address.
entryPoints:
ssh:
address: ":2222"
web:
address: ":80"
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
file:
filename: "/dynamic_conf.yml"
watch: true
certificatesResolvers:
letsencrypt:
acme:
email: "your-email@example.com"
storage: "acme.json"
httpChallenge:
entryPoint: "web"
Updating the Docker Compose File
Finally, let's update our docker-compose.yml
to bring it all together.
- We add the
ip-api
service and tell it to build from the./ip-api
directory. - We pass it a secure
AUTH_TOKEN
as an environment variable. Remember to change this! - We add ports
80
and443
to thetraefik
service for the Let's Encrypt challenge and our API. - We add a volume for
acme.json
to persist certificates. You'll need to create this file before starting:touch acme.json && chmod 600 acme.json
.
version: '3'
services:
traefik:
image: "traefik:v2.9"
container_name: "traefik"
ports:
- "80:80"
- "443:443"
- "2222:2222" # My new public SSH port
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik.yml:/traefik.yml:ro"
- "./dynamic_conf.yml:/dynamic_conf.yml:ro"
- "./acme.json:/acme.json"
restart: unless-stopped
ip-api:
build: ./ip-api
environment:
# CHANGE THIS to a long, random, secure string
- AUTH_TOKEN=a_very_long_and_secure_secret_token_here
- IP_FILE_PATH=/data/dynamic_conf.yml
labels:
- "traefik.enable=true"
# CHANGE THIS to your actual domain
- "traefik.http.routers.homeip-api.rule=Host(`api.your-domain.com`)"
- "traefik.http.routers.homeip-api.entrypoints=websecure"
- "traefik.http.services.homeip-api.loadbalancer.server.port=8080"
- "traefik.http.routers.homeip-api.tls=true"
- "traefik.http.routers.homeip-api.tls.certResolver=letsencrypt"
volumes:
- ./dynamic_conf.yml:/data/dynamic_conf.yml
restart: unless-stopped
Step 5: A Simple Script to Keep My IP Updated
The final piece of the puzzle is a script on my local machine that runs every few minutes. It grabs my current public IP and sends it to my web service.
Here's the bash script I use:
#!/bin/bash
IP=$(curl -s ifconfig.me)
SECRET_TOKEN="my-super-secret-token"
API_ENDPOINT="https://my-server.com/update-ip"
curl -X POST -H "Authorization: Bearer $SECRET_TOKEN" -H "Content-Type: application/json" -d "{\"ip\": \"$IP\"}" $API_ENDPOINT
I have this running as a cron job, so I never have to think about it.
Was It Worth It?
Absolutely. It was a bit of work to set up, but now my SSH port is essentially invisible to the internet at large. I can travel and access my server without worrying about my IP changing. It's a huge peace of mind.