Vyntel

Building a Graceful Proxy Rotation System with VPN Containers

How to build a self-hosted proxy rotation system with Docker VPN containers that waits for active operations before rotating and forces a rotation only after a maximum wait.

, 9 min read. Filed under engineering, docker, vpn, proxy, devops, infrastructure.

Applications that need many IP addresses usually pay for commercial proxies and get uneven quality in return. This is a self-hosted alternative: VPN containers on Docker that rotate IPs on a schedule, with a distributed lock that tracks active operations so that a rotation normally waits for them to finish. After a maximum wait the rotation is forced, which can interrupt whatever is still running; that trade keeps a stuck operation from blocking rotation forever.

Why rotate IPs yourself

Some jobs need requests to leave from more than one address: spreading load, testing from different regions, keeping a path alive when one fails, collecting public data, or staying inside per-address rate limits. The usual options fall short in specific ways. A static proxy is a single point of failure with no automatic failover. A commercial rotating-proxy service charges every month, shares its address pool with other customers, and decides rotation timing for you. Switching a VPN by hand gives no programmatic control and drops every active connection.

The design

The system rotates on a schedule while a lock tracks active operations, so that a rotation normally waits for them to complete and is forced only after the maximum wait.

System Architecture

┌─────────────────────────────────────────────────┐
│             Application Layer                   │
├─────────────────────────────────────────────────┤
│                                                 │
│  ┌──────────────┐      ┌──────────────┐         │
│  │  Service A   │      │  Service B   │         │
│  └──────┬───────┘      └──────┬───────┘         │
│         │                     │                 │
│         ▼                     ▼                 │
│  ┌────────────────────────────────────┐         │
│  │     HTTP Proxy Interface           │         │
│  │        (Port 8888)                 │         │
│  └──────────────┬─────────────────────┘         │
│                 │                               │
├─────────────────┼───────────────────────────────┤
│   VPN Layer     │                               │
│                 ▼                               │
│  ┌────────────────────────────────────┐         │
│  │     VPN Container (Gluetun)        │         │
│  │                                    │         │
│  │  ┌─────────┐     ┌──────────┐      │         │
│  │  │ OpenVPN │     │ HTTP API │      │         │
│  │  │ Client  │     │ Control  │      │         │
│  │  └─────────┘     └──────────┘      │         │
│  └────────────────────────────────────┘         │
│                                                 │
├─────────────────────────────────────────────────┤
│           Orchestration Layer                   │
│                                                 │
│  ┌──────────────┐    ┌──────────────┐           │
│  │ Lock Manager │    │  Scheduler   │           │
│  │   (Redis)    │    │   Service    │           │
│  └──────────────┘    └──────────────┘           │
└─────────────────────────────────────────────────┘

Core Components

1. VPN Container Configuration

The foundation is a containerized VPN client that provides network isolation and proxy capabilities:

version: '3.8'

services:
  vpn-proxy:
    image: qmcgaw/gluetun:latest
    container_name: vpn_proxy
    restart: always
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    ports:
      - "8888:8888/tcp"  # HTTP proxy interface
      - "8000:8000/tcp"  # Control API
    environment:
      - VPN_SERVICE_PROVIDER=${VPN_PROVIDER}
      - VPN_TYPE=openvpn
      - OPENVPN_USER=${VPN_USERNAME}
      - OPENVPN_PASSWORD=${VPN_PASSWORD}

      # Proxy configuration
      - HTTPPROXY=on
      - HTTPPROXY_LISTENING_ADDRESS=:8888

      # Control API
      - HTTP_CONTROL_SERVER_ADDRESS=:8000
      - HTTP_CONTROL_SERVER_LOG=on

      # Auto-update server lists
      - UPDATER_PERIOD=24h
    networks:
      - app_network
    dns:
      - 1.1.1.1
      - 1.0.0.1

2. Operation Tracking System

To prevent interruptions, we track active operations using distributed locks:

import time
import logging
from typing import Optional
from pathlib import Path

logger = logging.getLogger(__name__)


class OperationTracker:
    """Tracks active operations for graceful rotation."""

    LOCK_PREFIX = 'operation:active:'
    LOCK_TTL = 600  # 10 minutes

    def __init__(self, cache_client):
        self.cache = cache_client

    def start_operation(self, operation_id: str) -> bool:
        """Mark an operation as active."""
        lock_key = f"{self.LOCK_PREFIX}{operation_id}"

        try:
            # Set lock with TTL to prevent stale locks
            success = self.cache.set(lock_key, True, self.LOCK_TTL)
            if success:
                logger.info(f"Started tracking operation: {operation_id}")
            return success
        except Exception as e:
            logger.error(f"Failed to set lock for {operation_id}: {e}")
            return False

    def end_operation(self, operation_id: str) -> bool:
        """Mark an operation as complete."""
        lock_key = f"{self.LOCK_PREFIX}{operation_id}"

        try:
            success = self.cache.delete(lock_key)
            if success:
                logger.info(f"Completed operation: {operation_id}")
            return success
        except Exception as e:
            logger.error(f"Failed to release lock for {operation_id}: {e}")
            return False

    def get_active_operations(self) -> list:
        """Get list of all active operations."""
        pattern = f"{self.LOCK_PREFIX}*"
        return self.cache.keys(pattern)

3. Intelligent Rotation Logic

The rotation system checks for active operations and implements a maximum wait threshold:

import requests
from datetime import datetime
from typing import Tuple

class IntelligentRotator:
    """Manages proxy rotation with operation awareness."""

    LAST_ROTATION_KEY = 'rotation:last_timestamp'
    MAX_WAIT_MINUTES = 30
    CONTROL_API_URL = 'http://vpn-proxy:8000'

    def __init__(self, cache_client, tracker):
        self.cache = cache_client
        self.tracker = tracker

    def should_rotate(self) -> Tuple[bool, str]:
        """Determine if rotation should proceed."""
        active_ops = self.tracker.get_active_operations()

        if not active_ops:
            return True, "No active operations"

        # Check if we've been waiting too long
        last_rotation = self.cache.get(self.LAST_ROTATION_KEY)
        if last_rotation:
            current_time = datetime.now().timestamp()
            wait_time = (current_time - last_rotation) / 60

            if wait_time >= self.MAX_WAIT_MINUTES:
                logger.warning(
                    f"Force rotation: {len(active_ops)} active operations "
                    f"but waited {wait_time:.1f} minutes"
                )
                return True, f"Max wait time exceeded ({wait_time:.1f} min)"

        return False, f"{len(active_ops)} active operations"

    def rotate_proxy(self) -> bool:
        """Execute proxy rotation via VPN control API."""
        should_proceed, reason = self.should_rotate()

        if not should_proceed:
            logger.info(f"Skipping rotation: {reason}")
            return False

        try:
            # Stop VPN to trigger reconnection
            logger.info("Stopping VPN connection...")
            stop_resp = requests.put(
                f'{self.CONTROL_API_URL}/v1/openvpn/status',
                json={'status': 'stopped'},
                timeout=10
            )

            if stop_resp.status_code != 200:
                logger.error(f"Failed to stop VPN: {stop_resp.status_code}")
                return False

            # Brief pause for clean shutdown
            time.sleep(2)

            # Restart VPN (will select new server)
            logger.info("Starting VPN connection...")
            start_resp = requests.put(
                f'{self.CONTROL_API_URL}/v1/openvpn/status',
                json={'status': 'running'},
                timeout=10
            )

            if start_resp.status_code != 200:
                logger.error(f"Failed to start VPN: {start_resp.status_code}")
                return False

            # Update rotation timestamp
            self.cache.set(
                self.LAST_ROTATION_KEY,
                datetime.now().timestamp()
            )

            logger.info("Proxy rotation completed successfully")
            return True

        except Exception as e:
            logger.error(f"Rotation failed: {e}")
            return False

4. Client Implementation

Here's how applications interact with the system:

import uuid
import requests
from contextlib import contextmanager

class ProxyClient:
    """Client for making requests through rotating proxy."""

    def __init__(self, proxy_url: str, tracker: OperationTracker):
        self.proxy_url = proxy_url
        self.tracker = tracker
        self.session = self._create_session()

    def _create_session(self):
        """Create configured session with proxy."""
        session = requests.Session()
        session.proxies = {
            'http': self.proxy_url,
            'https': self.proxy_url
        }
        return session

    @contextmanager
    def tracked_operation(self, operation_id: str = None):
        """Context manager for tracked operations."""
        if not operation_id:
            operation_id = str(uuid.uuid4())

        try:
            # Mark operation as active
            self.tracker.start_operation(operation_id)
            yield self.session
        finally:
            # Always release the lock
            self.tracker.end_operation(operation_id)

    def get(self, url: str, **kwargs):
        """Make tracked GET request."""
        with self.tracked_operation() as session:
            return session.get(url, **kwargs)

    def post(self, url: str, **kwargs):
        """Make tracked POST request."""
        with self.tracked_operation() as session:
            return session.post(url, **kwargs)

5. Scheduled Rotation

Automatic rotation runs periodically:

import schedule
import threading
import time

class RotationScheduler:
    """Manages periodic proxy rotation."""

    def __init__(self, rotator, interval_minutes=5):
        self.rotator = rotator
        self.interval = interval_minutes
        self.running = False
        self.thread = None

    def _rotation_job(self):
        """Execute rotation job."""
        try:
            success = self.rotator.rotate_proxy()
            if success:
                logger.info(f"Scheduled rotation completed")
            else:
                logger.warning(f"Scheduled rotation skipped or failed")
        except Exception as e:
            logger.error(f"Rotation job error: {e}")

    def _run_schedule(self):
        """Run the schedule loop."""
        schedule.every(self.interval).minutes.do(self._rotation_job)

        while self.running:
            schedule.run_pending()
            time.sleep(1)

    def start(self):
        """Start the scheduler."""
        if self.running:
            return

        self.running = True
        self.thread = threading.Thread(target=self._run_schedule)
        self.thread.daemon = True
        self.thread.start()
        logger.info(f"Started rotation scheduler (every {self.interval} min)")

    def stop(self):
        """Stop the scheduler."""
        self.running = False
        if self.thread:
            self.thread.join(timeout=5)
        logger.info("Stopped rotation scheduler")

Advanced Features

Health Monitoring

class ProxyHealthMonitor:
    """Monitor proxy system health."""

    def __init__(self, proxy_url: str, control_url: str):
        self.proxy_url = proxy_url
        self.control_url = control_url

    def check_health(self) -> dict:
        """Comprehensive health check."""
        health = {
            'proxy_accessible': False,
            'vpn_connected': False,
            'external_ip': None,
            'response_time_ms': None
        }

        try:
            # Check proxy accessibility and response time
            start_time = time.time()
            resp = requests.get(
                'https://api.ipify.org?format=json',
                proxies={'http': self.proxy_url, 'https': self.proxy_url},
                timeout=5
            )
            response_time = (time.time() - start_time) * 1000

            if resp.status_code == 200:
                health['proxy_accessible'] = True
                health['external_ip'] = resp.json().get('ip')
                health['response_time_ms'] = round(response_time, 2)

            # Check VPN status
            vpn_resp = requests.get(
                f'{self.control_url}/v1/openvpn/status',
                timeout=5
            )
            if vpn_resp.status_code == 200:
                health['vpn_connected'] = vpn_resp.json().get('status') == 'running'

        except Exception as e:
            logger.error(f"Health check failed: {e}")

        return health

Retry Logic

from typing import Callable, Any
import random
import time

class RetryableProxy:
    """Proxy client with exponential backoff retry."""

    def __init__(self, proxy_client, max_retries=3):
        self.client = proxy_client
        self.max_retries = max_retries

    def _exponential_backoff(self, attempt: int) -> float:
        """Calculate backoff time with jitter."""
        base_delay = 2 ** attempt
        jitter = random.uniform(0, 1)
        return base_delay + jitter

    def execute_with_retry(self,
                          func: Callable,
                          *args,
                          **kwargs) -> Any:
        """Execute function with retry logic."""
        last_exception = None

        for attempt in range(self.max_retries):
            try:
                return func(*args, **kwargs)
            except Exception as e:
                last_exception = e
                if attempt < self.max_retries - 1:
                    delay = self._exponential_backoff(attempt)
                    logger.warning(
                        f"Attempt {attempt + 1} failed: {e}. "
                        f"Retrying in {delay:.1f} seconds..."
                    )
                    time.sleep(delay)
                else:
                    logger.error(f"All {self.max_retries} attempts failed")

        raise last_exception

Production Deployment

Security Considerations

version: '3.8'

services:
  vpn-proxy:
    image: qmcgaw/gluetun:latest
    container_name: vpn_proxy_prod
    restart: always
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    ports:
      # Bind to localhost only for security
      - "127.0.0.1:8888:8888"
      - "127.0.0.1:8000:8000"
    environment:
      - VPN_SERVICE_PROVIDER=${VPN_PROVIDER}
      - VPN_TYPE=openvpn
      - OPENVPN_USER=${VPN_USERNAME}
      - OPENVPN_PASSWORD=${VPN_PASSWORD}
      - HTTPPROXY=on
      - HTTP_CONTROL_SERVER_ADDRESS=:8000
    networks:
      - internal_network
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
    healthcheck:
      test: ["CMD", "wget", "-q", "--tries=1", "--spider", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

networks:
  internal_network:
    driver: bridge
    internal: true  # No external access

Monitoring and Alerting

class ProxyMetrics:
    """Collect and expose metrics for monitoring."""

    def __init__(self):
        self.metrics = {
            'rotations_total': 0,
            'rotations_failed': 0,
            'operations_completed': 0,
            'operations_interrupted': 0,
            'current_ip': None,
            'uptime_seconds': 0
        }
        self.start_time = time.time()

    def record_rotation(self, success: bool):
        """Record rotation attempt."""
        self.metrics['rotations_total'] += 1
        if not success:
            self.metrics['rotations_failed'] += 1

    def record_operation(self, completed: bool):
        """Record operation completion."""
        if completed:
            self.metrics['operations_completed'] += 1
        else:
            self.metrics['operations_interrupted'] += 1

    def get_metrics(self) -> dict:
        """Get current metrics."""
        self.metrics['uptime_seconds'] = int(time.time() - self.start_time)
        return self.metrics.copy()

What it does in practice

With the defaults above, rotation runs on a five-minute schedule and a rotation including reconnection takes a few seconds. An operation that is in flight is normally left alone, because the lock delays rotation until it finishes; if it runs past the maximum wait, the forced rotation interrupts it, and the retry logic above is what recovers. Success rates and cost depend on the VPN provider, the server list and the workload; measure them for your own setup with the metrics from the monitoring section rather than assuming a number.

The same system serves any job that needs requests to leave from more than one address or region, such as checking regional pricing, staying inside per-address rate limits, or watching a service from several vantage points.

Frequently Asked Questions

Q: What makes this different from using a regular VPN? A: This system provides programmatic control, automatic rotation, and operation tracking that consumer VPNs don't offer.

Q: How many IPs can this system access? A: It depends on the VPN provider and the server list you configure; the system exposes whatever servers your account can reach.

Q: What's the minimum rotation interval? A: Technically seconds, but 5-minute intervals balance stability with IP diversity.

Q: Can this scale horizontally? A: Yes, run multiple containers with different credentials and load balance between them.

Q: What about IPv6 support? A: Most VPN providers are IPv4-only. Verify with your provider for IPv6 support.

Q: Is this legal to use? A: The technology is legal, but ensure your usage complies with all applicable terms of service and regulations.

Q: What's the typical bandwidth overhead? A: VPN encryption adds some bandwidth overhead; measure it for your provider and plan capacity from that.

Q: How do you handle VPN connection drops? A: Docker's restart policy ensures automatic recovery. Implement retry logic for resilience.

Q: Can this work with SOCKS5 proxies? A: Yes, Gluetun supports multiple proxy protocols including SOCKS5.

Q: What monitoring should be implemented? A: Monitor connection status, rotation success rate, operation completion rate, and response times.

This entry first appeared in Vyntel’s earlier journal at the same address. Back to the journal