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.

Secure SSH Access with Traefik and Whitelist Middleware
This diagram illustrates how Traefik acts as a proxy with whitelist middleware to allow SSH connections from the internet only for approved IP addresses, securely forwarding them to the internal SSH service.

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 the ip-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 and 443 to the traefik 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.

Share this post: