Building a Cross-City LAN with Docker
Overview
At the time, I had servers both at home and at the office and wanted unified management, but the two internal networks were isolated. Traditional port forwarding was too cumbersome, and some services I didn’t want exposed to the public internet. After trying several approaches, I found Docker OpenVPN to be the most hassle-free solution.
I chose Docker OpenVPN over traditional VPN mainly because containerized deployment is extremely convenient, and certificate management is much simpler. The end result was great — I could access various services on the remote internal network via domain names, saving a lot of configuration hassle.
Network Topology
graph TD
classDef gateway fill:#e67e22,stroke:#d35400,color:#fff,stroke-width:2px
classDef lb fill:#2980b9,stroke:#2471a3,color:#fff,stroke-width:2px
classDef proxy fill:#5dade2,stroke:#2e86c1,color:#fff,stroke-width:1px
classDef vpn fill:#5dade2,stroke:#2e86c1,color:#fff,stroke-width:1px
classDef arm fill:#85c1e9,stroke:#2e86c1,color:#333,stroke-width:1px
classDef isp fill:#76d7c4,stroke:#1abc9c,color:#333,stroke-width:1px
classDef user fill:#bdc3c7,stroke:#7f8c8d,color:#333,stroke-width:1px
subgraph overseas["Overseas Service Zone"]
UserOut@{ shape: rounded, label: "External User" }:::user
OS@{ shape: rounded, label: "Overseas Service" }:::user
CDN@{ shape: rounded, label: "CDN" }:::user
SS1@{ shape: hex, label: "SS Server 1" }:::proxy
SS2@{ shape: hex, label: "SS Server 2" }:::proxy
UserOut -->|User Access| OS
OS -->|User Access| CDN
CDN -->|User Access| SS2
OS -->|Shadowsocks| SS1
OS -->|Shadowsocks| SS2
end
GFW@{ shape: hex, label: "GFW Firewall" }:::gateway
LB@{ shape: hex, label: "Load Balancer" }:::lb
subgraph internal["Internal Core Zone"]
subgraph proxyVpn["Access Proxy & VPN"]
UserIn@{ shape: rounded, label: "Internal User" }:::user
Nginx@{ shape: hex, label: "Nginx" }:::proxy
Haproxy@{ shape: hex, label: "Haproxy" }:::proxy
OpenVPN@{ shape: hex, label: "OpenVPN" }:::vpn
UserIn -->|User Access| Nginx
UserIn -->|User Access| Haproxy
Nginx <-->|DockerNet| Haproxy
Haproxy -->|DockerNet| OpenVPN
end
subgraph armCluster["Multi-ISP ARM Cluster"]
ARM1@{ shape: rounded, label: "ARM1" }:::arm --> Telecom@{ shape: hex, label: "Telecom AD" }:::isp
ARM2@{ shape: rounded, label: "ARM2" }:::arm --> Mobile@{ shape: hex, label: "Mobile AD" }:::isp
ARM3@{ shape: rounded, label: "ARM3" }:::arm --> Unicom@{ shape: hex, label: "Unicom AD" }:::isp
end
Mickey@{ shape: rounded, label: "Mickey" }:::user
end
SS1 -->|Shadowsocks| GFW
SS2 -->|Shadowsocks| GFW
GFW -->|Shadowsocks| LB
LB -->|SS| ARM1
LB -->|SS| ARM2
LB -->|SS| ARM3
SS1 -.->|TCP BBR| GFW
GFW -.->|BBR| ARM1
GFW -.->|BBR| ARM2
GFW -.->|BBR| ARM3
OpenVPN -->|VPN Tunnel| Mickey
Telecom -->|VPN| Mickey
Mobile -->|VPN| Mickey
Unicom -->|VPN| MickeyThe overall architecture is divided into three zones: the overseas service zone traverses the firewall via Shadowsocks, passes through GFW and load balancing before being distributed to the internal ARM server cluster. The ARM boards are connected to three ISP lines — Telecom, Mobile, and Unicom — as egress points. The internal network uses Nginx/Haproxy as proxy access points, and OpenVPN provides VPN tunnels for personal users to access remotely.
Proxy Flow
graph TD
classDef client fill:#27ae60,stroke:#1e8449,color:#fff,stroke-width:2px
classDef proxy7 fill:#5dade2,stroke:#2471a3,color:#fff,stroke-width:1px
classDef streamSelect fill:#e67e22,stroke:#d35400,color:#fff,stroke-width:1px
classDef stream fill:#76d7c4,stroke:#1abc9c,color:#333,stroke-width:1px
classDef cache fill:#27ae60,stroke:#1e8449,color:#fff,stroke-width:2px
classDef lb7 fill:#e67e22,stroke:#d35400,color:#fff,stroke-width:1px
classDef backend fill:#27ae60,stroke:#1e8449,color:#fff,stroke-width:2px
classDef moduleBox fill:#ecf0f1,stroke:#7f8c8d,stroke-width:1px
subgraph L1_7["Layer 1 Proxy · Layer 7"]
direction TB
C@{ shape: rounded, label: "Client" }:::client
A1@{ shape: hex, label: "Layer 1 Proxy" }:::proxy7
S1@{ shape: hex, label: "Stream to Layer 1" }:::streamSelect
Cache@{ shape: cyl, label: "Cache" }:::cache
C --> A1
A1 --> S1
S1 --> Cache
end
subgraph L1_4["Layer 1 Proxy · Layer 4"]
direction TB
ST1@{ shape: hex, label: "Layer 1 Stream" }:::stream
SE1@{ shape: hex, label: "Select Next Layer Stream" }:::streamSelect
S1 --> ST1
ST1 --> SE1
end
subgraph L2_4["Layer 2 Proxy · Layer 4"]
direction TB
ST2@{ shape: hex, label: "Layer 2 Stream" }:::stream
SE2@{ shape: hex, label: "Select Next Layer Stream" }:::streamSelect
SE1 --> ST2
ST2 --> SE2
end
subgraph L3_7["Layer 3 Proxy · Layer 7"]
direction TB
A3@{ shape: hex, label: "Layer 3 Proxy" }:::proxy7
LB7@{ shape: hex, label: "Layer 7 Load Balancer" }:::lb7
Backend@{ shape: rounded, label: "Backend System" }:::backend
SE2 --> A3
A3 --> LB7
LB7 --> Backend
endStep-by-Step Guide
Purchase a Cloud Server and Deploy Docker
You need a public VPS as a relay, because OpenVPN requires a public entry point for clients to connect to. It’s recommended to purchase a Linux system that supports systemctl for easier management, and deploy Docker:
- Format external storage as an XFS partition:
1 2# mkfs.xfs /dev/vdb5 # echo "/dev/vdb5 /mnt/data xfs defaults 1 1" |tee -a /etc/fstab- Adjust the Docker directory using one of two methods:
- Mount the /var/lib/docker directory:
1 2 3 4 5 6# systemctl stop docker # mkdir -p /mnt/data/docker # rsync -aXS /var/lib/docker/. /mnt/data/docker/ # echo "/mnt/data/docker /var/lib/docker none bind 0 0"|tee -a /etc/fstab # mount -a # systemctl start docker- Specify a custom directory: Modify /etc/systemd/system/multi-user.target.wants/docker.service as follows:
1ExecStart=/usr/bin/dockerd --storage-driver=overlay2 -g /mnt/hhd/dockerInstall the docker-openvpn Service
- Pick a name for the $OVPN_DATA data volume container, it will be created automatically.
1# OVPN_DATA="ovpn-data"- Initialize the $OVPN_DATA container that will hold the configuration files and certificates
1 2 3 4 5 6# docker volume create --name $OVPN_DATA # docker run -v $OVPN_DATA:/etc/openvpn \ --rm kylemanna/openvpn ovpn_genconfig \ -u udp://VPN.SERVERNAME.COM # docker run -v $OVPN_DATA:/etc/openvpn \ --rm -it kylemanna/openvpn ovpn_initpkiIf using TCP
1 2 3# docker run -v $OVPN_DATA:/etc/openvpn \ --rm kylemanna/openvpn ovpn_genconfig \ -u tcp://VPN.SERVERNAME.COM:1443- Start OpenVPN server process
1 2 3# docker run -v $OVPN_DATA:/etc/openvpn -d \ -p 1194:1194/udp \ --cap-add=NET_ADMIN kylemanna/openvpnOR
1 2 3# docker run -v $OVPN_DATA:/etc/openvpn -d \ -p 1443:1194/tcp \ --cap-add=NET_ADMIN kylemanna/openvpnRunning a Second Fallback TCP Container
1 2 3 4# docker run -v $OVPN_DATA:/etc/openvpn \ --rm -p 1443:1194/tcp \ --privileged kylemanna/openvpn ovpn_run \ --proto tcp- Generate a client certificate without a passphrase. Retrieve the client configuration with embedded certificates. “CLIENTNAME” can be customized:
1 2 3 4# docker run -v $OVPN_DATA:/etc/openvpn \ --rm -it kylemanna/openvpn easyrsa build-client-full CLIENTNAME nopass # docker run -v $OVPN_DATA:/etc/openvpn \ --rm kylemanna/openvpn ovpn_getclient CLIENTNAME > CLIENTNAME.ovpn- Add routing rules so that the Docker internal network can access VPN client nodes Add a routing rule on the Docker host so that other containers can access OpenVPN client nodes through the default network:
1# ip route add 192.168.255.0/24 via $DOCKER_OPENVPN_IP- Configure static internal/external IPs for clients to ensure consistent IPs across connections, simplifying proxy configuration
1 2# cat ccd/CLIENTNAME ifconfig-push 192.168.255.10 192.168.255.9Deploy Frontend Proxy
- Use DOCKER-CADDY as the reverse proxy, mainly for automatic HTTPS and simple configuration
| |
- Caddy configuration reference:
| |
Deploy TCP Proxy
- Use DOCKER-HAPROXY for deployment, suitable for handling database connections, SSH, and other long-lived connections
| |
Map Backend Ports
Add backend service mappings in the HAProxy configuration file, specifying the IP addresses and port numbers, supporting both TCP and HTTP proxy modes. Restart the HAProxy service after configuration changes to apply them.