ReadeckProxy

Customizable HTTP/HTTPS MITM proxy service built with Swift and Vapor. Intercepts HTTP and HTTPS requests, injects headers based on configurable rules, forwards to upstream servers, and logs all activity to PostgreSQL. Runs entirely in Docker.

Focused guides (networking, hybrid Compose, scripts, MCP): docs/INDEX.md.

Tech Stack

  • Swift 6.3 with Vapor 4 framework
  • SwiftNIO + NIOSSL for high-performance proxy I/O
  • swift-certificates (X509) + swift-crypto for CA and per-host TLS certificate generation
  • Fluent ORM with PostgreSQL 18.3 driver
  • Leaf templating + Bootstrap 5 for admin UI
  • Docker + Docker Compose

Project Structure

ReadeckProxy/
├── Package.swift
├── docker-compose.yml
├── .env                        # Environment configuration (gitignored)
├── .env.example                # Environment template
├── docker/
│   ├── app/
│   │   ├── Dockerfile          # Development container (Swift 6.3)
│   │   ├── Dockerfile.release  # Production multi-stage build
│   │   └── watch.sh            # File watcher for hot-reload
│   └── db/
│       └── Dockerfile          # PostgreSQL 18.3 database container
├── scripts/
│   └── release.sh              # Build & push release image to Nexus
├── Sources/App/
│   ├── entrypoint.swift
│   ├── configure.swift
│   ├── routes.swift
│   ├── Controllers/
│   │   ├── AuthController.swift
│   │   └── AdminController.swift
│   ├── Models/
│   │   ├── ProxyLog.swift
│   │   ├── User.swift
│   │   ├── NetworkSetting.swift
│   │   └── HostRule.swift
│   ├── Migrations/
│   │   ├── CreateProxyLog.swift
│   │   ├── CreateUser.swift
│   │   ├── CreateNetworkSetting.swift
│   │   ├── CreateHostRule.swift
│   │   └── AddUpdatedAtToHostRule.swift
│   ├── Middleware/
│   │   └── AdminAuthMiddleware.swift
│   └── Proxy/
│       ├── RulesCache.swift            # Thread-safe rule cache with wildcard matching
│       ├── ProxyServer.swift           # NIO ServerBootstrap listener management
│       ├── ProxyChannelHandler.swift   # HTTP/HTTPS proxy + MITM pipeline
│       ├── CertificateManager.swift    # CA generation, per-host cert signing
│       └── ProxyLifecycleHandler.swift # Vapor lifecycle integration
├── Resources/Views/
│   ├── base.leaf
│   ├── login.leaf
│   └── settings/
│       ├── index.leaf
│       ├── show.leaf
│       └── rules/
│           ├── create.leaf
│           └── edit.leaf
├── Public/                     # Static files served by FileMiddleware
└── Tests/AppTests/
    └── AppTests.swift

Prerequisites

Getting Started

Configure environment

cp .env.example .env

Edit .env to customize ports, database credentials, and DOCKER_NETWORK (must match the bridge created by ./scripts/create-docker-network.sh; default readeck_proxy_net).

Create the Docker network (first time)

./scripts/create-docker-network.sh

Start all services

docker compose up --build

This will: 0. (Prerequisite) External bridge DOCKER_NETWORK exists (./scripts/create-docker-network.sh).

  1. Run client-build (Node 22 in Docker): npm ci + npm run build → writes client/dist (no Node/npm required on the host)
  2. Build the Swift application container (swift:6.3) with pre-resolved dependencies
  3. Build the PostgreSQL container (postgres:18.3)
  4. Wait for the database to be healthy and for client-build to finish successfully
  5. Seed the build cache (if empty) from the pre-resolved image layer, then build and start the app

The app is available at http://localhost:8080. The admin panel is at http://localhost:8080/admin.

Verify it works

  1. Check the health endpoint:
curl http://localhost:8080/health

Should return 200 OK.

  1. Open the admin panel in your browser:
http://localhost:8080/admin

On the first visit you will see the Setup page — create your admin account.

  1. Create a Network Setting with listen port 9080 and add a Host Rule for httpbin.org with header X-Custom: test123.

  2. Test the HTTP proxy:

curl -x http://localhost:9080 http://httpbin.org/headers

You should see X-Custom: test123 in the response headers.

  1. Check proxy logs:
curl http://localhost:8080/proxy/logs

View logs

docker compose logs -f app

Stop services

docker compose down

Stop and remove all data

docker compose down -v

HTTPS Proxy (SSL MITM)

ReadeckProxy supports HTTPS interception via Man-in-the-Middle (MITM) proxying. When a client sends a CONNECT request, the proxy:

  1. Responds with 200 Connection Established
  2. Generates a TLS certificate for the target host (signed by the proxy's CA)
  3. Terminates TLS from the client, decrypts the request
  4. Matches rules and injects headers (same as HTTP)
  5. Connects to the upstream server over TLS and forwards the request
  6. Relays the response back to the client through TLS

How it works

Client ──CONNECT host:443──► ReadeckProxy
  ◄── 200 Connection Established ──
Client ──TLS handshake──► Proxy (using generated cert for host)
Client ──decrypted HTTP──► Proxy
  → matches rules, injects headers
  → connects to upstream over TLS
  ◄── relays response back through TLS ──

The proxy generates a self-signed CA certificate on first startup. All per-host certificates are dynamically generated and signed by this CA. The CA certificate and key are persisted to disk (in CA_CERT_DIR) so they survive restarts.

Step-by-step tutorial

1. Start the proxy

docker compose up --build

2. Download the CA certificate

curl -o readeck-proxy-ca.pem http://localhost:8080/ca/cert.pem

Or download it from the admin panel — the Settings page has download buttons for PEM and DER formats.

3. Install the CA certificate

You must install and trust the CA certificate so your client (browser, curl, Docker container, etc.) trusts the proxy's generated certificates.

macOS (system-wide):

sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain readeck-proxy-ca.pem

Linux (system-wide):

sudo cp readeck-proxy-ca.pem /usr/local/share/ca-certificates/readeck-proxy-ca.crt
sudo update-ca-certificates

Windows:

certutil -addstore -f "ROOT" readeck-proxy-ca.pem

Firefox (uses its own cert store): Settings > Privacy & Security > Certificates > View Certificates > Authorities > Import > select the PEM file > check "Trust this CA to identify websites"

4. Configure rules

In the admin panel (http://localhost:8080/admin):

  1. Create a Network Setting with listen port 9080
  2. Add a Host Rule for httpbin.org with header Authorization: Bearer my-secret-token

5. Test HTTPS proxying

With the CA installed system-wide:

curl -x http://localhost:9080 https://httpbin.org/headers

Or using the CA cert file directly:

curl --cacert readeck-proxy-ca.pem -x http://localhost:9080 https://httpbin.org/headers

You should see your injected Authorization header in the response.

6. Check logs

curl http://localhost:8080/proxy/logs

HTTPS requests are logged with the https:// scheme.

Adding the CA certificate to Docker containers

If other Docker containers need to make HTTPS requests through the proxy, they must trust the proxy's CA certificate.

Option A: Mount the CA cert at build time (Dockerfile)

Download the CA cert from the running proxy and add it to your image:

FROM ubuntu:noble

# Copy the proxy CA certificate
COPY readeck-proxy-ca.pem /usr/local/share/ca-certificates/readeck-proxy-ca.crt
RUN update-ca-certificates

# Your application setup...

Option B: Mount the CA cert at runtime (docker-compose)

Share the CA data volume with other containers:

services:
  app:
    # ... ReadeckProxy config ...
    volumes:
      - ca-data:/app/data/ca

  my-service:
    image: my-app:latest
    environment:
      # Tell your app to use the proxy
      HTTP_PROXY: http://app:9080
      HTTPS_PROXY: http://app:9080
    volumes:
      - ca-data:/certs/proxy-ca:ro
    # Install the CA cert on startup
    entrypoint: >
      sh -c "
        cp /certs/proxy-ca/ca-cert.pem /usr/local/share/ca-certificates/readeck-proxy-ca.crt &&
        update-ca-certificates &&
        exec my-app
      "

volumes:
  ca-data:

Option C: Use environment variables for specific tools

Some tools support specifying a CA cert via environment variable:

services:
  my-service:
    environment:
      HTTP_PROXY: http://app:9080
      HTTPS_PROXY: http://app:9080
      # For curl
      CURL_CA_BUNDLE: /certs/proxy-ca/ca-cert.pem
      # For Node.js
      NODE_EXTRA_CA_CERTS: /certs/proxy-ca/ca-cert.pem
      # For Python requests
      REQUESTS_CA_BUNDLE: /certs/proxy-ca/ca-cert.pem
      # For SSL_CERT_FILE (OpenSSL-based tools)
      SSL_CERT_FILE: /certs/proxy-ca/ca-cert.pem
    volumes:
      - ca-data:/certs/proxy-ca:ro

Option D: Alpine-based containers

Alpine uses a different certificate path:

FROM alpine:3.20

COPY readeck-proxy-ca.pem /usr/local/share/ca-certificates/readeck-proxy-ca.crt
RUN apk add --no-cache ca-certificates && update-ca-certificates

CA certificate endpoints

Endpoint Format Content-Type
GET /ca/cert.pem PEM (Base64) application/x-pem-file
GET /ca/cert.der DER (Binary) application/x-x509-ca-cert

Admin Panel

The admin panel is available at /admin and provides a web UI for configuring proxy behavior.

First Time Setup

On first visit to /admin, you will be prompted to create an admin account. Enter a username and password — this becomes the only account that can log in.

Network Settings

After logging in, the main page shows a list of port mappings. Each port mapping represents a port that the proxy listens on for incoming requests from internal services.

Click on any entry to open its detail page.

Host Rules

Each port mapping contains a list of host rules. When a request comes through the proxy:

  1. The proxy receives the request on the configured listen port
  2. It inspects the destination host
  3. If the host matches a rule, the configured headers are added or overwritten
  4. The modified request is forwarded to the destination

Each rule defines:

  • Host Pattern — the destination host to match (e.g. api.example.com or *.example.com for wildcards). Protocol prefixes (http://, https://) and trailing slashes are stripped automatically.
  • Headers — key/value pairs of headers to add or rewrite on matching requests

Rules are managed through dedicated create and edit pages with dynamic header input fields. The edit page shows existing header names but hides values for security — you must re-enter values when updating. A "Last Edited" timestamp is shown in the rules list and on the edit page.

Static Files

Static files placed in the Public/ directory are served automatically via Vapor's FileMiddleware. Any file in this directory is accessible at the root URL path (e.g. Public/style.css is served at /style.css).

API Endpoints

Method Path Auth Description
GET /health No Health check — returns 200 OK
GET /proxy/logs No Last 100 proxy logs (JSON)
GET /ca/cert.pem No Download CA certificate (PEM format)
GET /ca/cert.der No Download CA certificate (DER format)
GET /admin/login No Login page
POST /admin/login No Login / first-time setup
GET /admin/settings Yes List network settings
POST /admin/settings Yes Create network setting
GET /admin/settings/:id Yes View setting + rules
POST /admin/settings/:id/delete Yes Delete setting
GET /admin/settings/:id/rules/create Yes Create rule page
POST /admin/settings/:id/rules Yes Create rule
GET /admin/settings/:id/rules/:ruleId/edit Yes Edit rule page
POST /admin/settings/:id/rules/:ruleId/update Yes Update rule
POST /admin/settings/:id/rules/:ruleId/delete Yes Delete rule

Development in Docker

All development happens inside the Docker container. The source code is mounted as a volume, so you edit files on your host machine and the container sees the changes immediately.

How it works

┌─────────────────────────────────────────────────┐
│  Host machine                                   │
│                                                 │
│  Sources/  ──volume mount──►  /app/Sources/     │
│  Resources/ ─volume mount──►  /app/Resources/   │
│  Public/    ─volume mount──►  /app/Public/      │
│  Package.swift ─vol mount──►  /app/Package.swift│
│                                                 │
│  .build/ is stored in a named Docker volume     │
│  (swift-build) so build cache persists between  │
│  container restarts. Dependencies are pre-      │
│  resolved in the Docker image layer and seeded  │
│  into the volume on first run.                  │
└─────────────────────────────────────────────────┘

Typical workflow

  1. Start the environment:
docker compose up --build
  1. Edit source files in your editor on the host machine.

  2. Restart the app container to pick up changes:

docker compose restart app
  1. Watch app logs in real time:
docker compose logs -f app
  1. Open a shell inside the running container for ad-hoc commands:
docker compose exec app bash

Build and run tests inside the container

docker compose exec app swift build
docker compose exec app swift test

Rebuild after changing Package.swift

When you add or update dependencies in Package.swift, rebuild the image so the new dependencies are resolved and cached in the image layer:

docker compose up --build

The Dockerfile pre-resolves dependencies during the image build. Docker layer caching ensures this step is skipped when Package.swift and Package.resolved haven't changed.

Reset everything

Stop all containers and remove volumes (database data + build cache + CA certificates):

docker compose down -v

Release

Build and push a release image

The script scripts/release.sh builds a production image using docker/app/Dockerfile.release and pushes it to the private registry registry.kshaitry.com by default (override with REGISTRY).

The production image:

  • Builds the React SPA in a Node stage (npm ci / npm run build) — no npm on the host required
  • Compiles the Swift binary in release mode with static stdlib
  • Copies the binary, Resources/, Public/, and client/dist into a minimal Ubuntu runtime image
  • Runs as a non-root user
./scripts/release.sh <version>

Example:

./scripts/release.sh 1.0.0

Environment overrides (optional): REGISTRY (default registry.kshaitry.com), IMAGE_NAME (default readeck-proxy), BASE_REGISTRY for Swift/Ubuntu base image prefix (default registry-group.kshaitry.com/library).

This will:

  1. Build registry.kshaitry.com/readeck-proxy:1.0.0 (defaults shown)
  2. Tag it as registry.kshaitry.com/readeck-proxy:latest
  3. Push both tags to the registry

Make sure you are logged in to the registry before running:

docker login registry.kshaitry.com

Push to Docker Hub

Use scripts/publish.sh to build a production image and push it to Docker Hub. This is the recommended workflow for deploying to remote servers.

Prerequisites

  • Docker installed and running locally
  • Logged into Docker Hub (docker login)
  • The variable DOCKER_HUB_USER set to your Docker Hub username

Quick start

DOCKER_HUB_USER=myuser ./scripts/publish.sh

This builds the image, tags it as myuser/readeckproxy:latest, and pushes both tags to Docker Hub.

Step-by-step

1. Log in to Docker Hub (if you haven't already)

docker login

2. Run the publish script

DOCKER_HUB_USER=myuser ./scripts/publish.sh

The script will:

  1. Verify Docker is available and Dockerfile.release / client/package-lock.json exist
  2. Build a production image (Node frontend stage + Swift release binary + Ubuntu runtime)
  3. Tag IMAGE_TAG (default latest) and also latest
  4. Push both tags to Docker Hub

You must run docker login beforehand so docker push can authenticate.

3. Verify the push

Visit your Docker Hub repository page or run:

docker pull myuser/readeckproxy:latest

4. Deploy on a remote server

# Pull the image
docker pull myuser/readeckproxy:latest

# Copy .env from the project, then start
cp .env.example .env
docker compose up -d

Customizing the image name and tag

DOCKER_HUB_USER=myuser IMAGE_NAME=readeck-proxy IMAGE_TAG=1.2.0 ./scripts/publish.sh

This publishes:

  • myuser/readeck-proxy:1.2.0
  • myuser/readeck-proxy:latest

Environment variables

Variable Default Description
DOCKER_HUB_USER (required) Your Docker Hub username
IMAGE_NAME readeckproxy Image name in the repository
IMAGE_TAG latest Image tag / version
BASE_REGISTRY registry-group.kshaitry.com/library Registry prefix for Swift builder and Ubuntu runtime base images

Example Readeck docker compose file

volumes:
  readeck-data:

services:
  app:
    image: codeberg.org/readeck/readeck:0.22.3
    container_name: readeck
    ports:
      - 8000:8000
    environment:
      # Defines the application log level. Can be error, warn, info, debug.
      READECK_LOG_LEVEL: info
      # The IP address on which Readeck listens.
      READECK_SERVER_HOST: "0.0.0.0"
      # The TCP port on which Readeck listens. Update container port above to match (right of colon).
      READECK_SERVER_PORT: 8000
      # Easier to read log format
      READECK_LOG_FORMAT: text
      # Optional, the URL prefix of Readeck.
      HTTP_PROXY: "http://172.20.0.3:9080"
      HTTPS_PROXY: "http://172.20.0.3:9080"
      SSL_CERT_FILE: /certs/readeck-proxy-ca.pem
      # READECK_SERVER_PREFIX: "/"
    volumes:
      - readeck-data:/readeck
      - ./readeck-proxy-ca.pem:/certs/readeck-proxy-ca.pem:ro
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "/bin/readeck", "healthcheck", "-config", "config.toml"]
      interval: 30s
      timeout: 2s
      retries: 3

Environment Variables

All settings are configured via the .env file (see .env.example for the template). Docker Compose reads this file automatically.

Variable Default Description
APP_PORT 8080 Application port (admin panel + API)
PROXY_PORTS 9080 Proxy listener port(s) exposed to host
CA_CERT_DIR data/ca Directory for CA certificate persistence
POSTGRES_USER readeck Database user
POSTGRES_PASSWORD readeck_pass Database password
POSTGRES_DB readeck_proxy Database name
POSTGRES_PORT 5432 PostgreSQL port exposed to host

Docker Volumes

Volume Purpose
db_data Persists PostgreSQL data between restarts
swift-build Caches .build directory for faster rebuilds
ca-data Persists CA certificate and key between restarts

Database Schema

All tables are auto-migrated on startup.

users

Column Type Description
id UUID Primary key
username String Unique username
password_hash String Bcrypt password hash
created_at DateTime Creation timestamp

network_settings

Column Type Description
id UUID Primary key
name String Display name
listen_port Int Port the proxy listens on
created_at DateTime Creation timestamp

host_rules

Column Type Description
id UUID Primary key
network_setting_id UUID FK to network_settings (cascade delete)
host_pattern String Destination host to match
headers JSON Headers to add/rewrite
created_at DateTime Creation timestamp
updated_at DateTime Last update timestamp

proxy_logs

Column Type Description
id UUID Primary key
method String HTTP method used
url String Target URL (http:// or https://)
status_code Int Response status code
request_headers JSON Injected headers
response_size Int Response body size in bytes
created_at DateTime Timestamp of the request
S
Description
No description provided
Readme 290 KiB
Languages
Swift 57.1%
JavaScript 29.8%
Shell 7.4%
CSS 4.8%
Dockerfile 0.6%
Other 0.3%