curl --resolve and --connect-to Change Different Layers
Last edited on August 14, 2026

Two successful HTTPS requests in the reproduced lab used the same URL, TLS name, certificate identity, and HTTP Host value. One reached TCP port 28443; the other reached 29443. The option made the difference: --resolve supplied an address for the URL’s existing host-and-port pair, while --connect-to replaced the connection destination, including its port.

That is the practical answer. Use --resolve when you know the IP address that should answer an exact URL host and port. Use --connect-to when the underlying connection host or port should change while curl continues to treat the URL as the request identity. Neither option rewrites the hostname curl uses for TLS Server Name Indication (SNI), certificate verification, or the application protocol.

This comparison is for developers and operators who can run Bash on an owned Linux test host. SNI is the hostname sent during the TLS handshake, before HTTP begins; Host is the HTTP authority sent after TLS succeeds. Run the seven inputs in order in one shell. They create a private certificate authority, bind three loopback-only listeners, make no public DNS change, send no production traffic, and clean only their marker-owned scope.

Put URL Identity and Connection Destination on Separate Lines

An HTTPS request has several names and addresses that often happen to match. That coincidence makes unsafe shortcuts look equivalent.

  • The URL authority is the hostname and optional port after https://.
  • curl derives TLS SNI and certificate verification from the URL hostname.
  • HTTP sends the URL authority as Host for HTTP/1.1 or :authority for HTTP/2 and HTTP/3.
  • The connection destination is the IP address and TCP port curl actually opens.

--resolve acts like a command-scoped DNS-cache entry. The current Everything curl name-resolution guide defines its key as an exact hostname and port, then supplies one or more addresses. In contrast, the current curl command-line manual defines --connect-to as a source host-and-port to destination host-and-port replacement used only for establishing the connection.

In both cases, the URL remains the source of truth for TLS and HTTP. Daniel Stenberg’s curl another host explanation makes the security consequence explicit: putting an IP address in an HTTPS URL and adding Host: later does not give curl the original hostname soon enough for SNI and certificate validation.

If verbose curl output is unfamiliar, review Voxfor’s curl command orientation before using an override in a deployment record. The lab below prints a smaller machine assertion so the decision does not depend on reading an entire -v transcript by eye.

Choose the Override From the Variable You Control

Start with the change you intend to test, not the flag you remember first.

Test goal URL authority TLS SNI / certificate name Wire destination Appropriate control
Send app.test:443 to a known canary IP on port 443 unchanged app.test chosen IP, port 443 --resolve app.test:443:IP
Send app.test:443 to another hostname resolved at request time unchanged app.test alternate host, port 443 --connect-to app.test:443:backend.test:443
Send app.test:443 to an alternate listener on port 8443 unchanged app.test alternate host/IP, port 8443 --connect-to app.test:443:HOST:8443
Test https://app.test:8443/ as its own public authority changed to port 8443 app.test resolved address, port 8443 put :8443 in the URL; optionally add --resolve

Both first rows can reach the same machine, but their maintenance properties differ. A literal IP makes the test reproducible for one endpoint. A replacement hostname lets curl resolve that backend at request time, which may intentionally follow its current A/AAAA set. Fastly’s updated origin-response testing guide highlights that tradeoff when a backend name has multiple addresses.

Port substitution is the sharper boundary. The port inside --resolve identifies the URL cache key; it is not a destination-port field. When the listener moves from 443 to 8443 but the user-facing URL must stay https://app.test/, --connect-to expresses the experiment directly.

Build a Loopback HTTPS Fixture With Three Identities

Three listeners create the required identity boundaries: two present a certificate valid for app.test, and one is valid only for wrong.test. That third listener proves curl did not silently start validating the connection hostname instead of the URL hostname.

Create the private certificate identity

Input one refuses a stale directory and three occupied ports. Its certificates last one day, remain inside /tmp, and are trusted only by commands that explicitly pass the generated CA file.

lab_root=/tmp/voxfor-curl-route-172
front_port=28443
canary_port=29443
wrong_port=30443
test ! -e "$lab_root"
for tool in curl openssl python3 grep sed sha256sum; do command -v "$tool" >/dev/null; done
for port in "$front_port" "$canary_port" "$wrong_port"; do
  if ss -lnt "sport = :$port" | grep -q LISTEN; then
    printf 'Refusing occupied TCP port: %s\n' "$port" >&2
    exit 9
  fi
done
install -d -m 0700 "$lab_root"
printf '%s\n' 'voxfor-curl-route-172' > "$lab_root/.owner-marker"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=Voxfor Curl Lab CA' \
  -keyout "$lab_root/ca.key" -out "$lab_root/ca.crt" >/dev/null 2>&1
for name in app.test wrong.test; do
  openssl req -newkey rsa:2048 -nodes -subj "/CN=$name" \
    -keyout "$lab_root/$name.key" -out "$lab_root/$name.csr" >/dev/null 2>&1
  printf 'subjectAltName=DNS:%s\nextendedKeyUsage=serverAuth\n' "$name" > "$lab_root/$name.ext"
  openssl x509 -req -days 1 -sha256 -in "$lab_root/$name.csr" \
    -CA "$lab_root/ca.crt" -CAkey "$lab_root/ca.key" -CAcreateserial \
    -extfile "$lab_root/$name.ext" -out "$lab_root/$name.crt" >/dev/null 2>&1
done
curl --version | sed -n '1p'
openssl x509 -in "$lab_root/app.test.crt" -noout -subject -ext subjectAltName

Our reproduced client was curl 8.14.1 with OpenSSL 3.5.6. Both options are much older than those versions, but recording the real client matters because proxy behavior, address-family support, TLS libraries, and structured --write-out fields vary between builds.

Start loopback listeners that report SNI and Host

A small Python server records four facts in its response: listener label, observed SNI, observed Host, and local TCP port. It binds only 127.0.0.1. Recorded PIDs and /proc command lines become the cleanup boundary.

cat > "$lab_root/tls_identity_server.py" <<'PY'
import argparse, http.server, ssl

parser = argparse.ArgumentParser()
parser.add_argument('--label', required=True)
parser.add_argument('--port', type=int, required=True)
parser.add_argument('--cert', required=True)
parser.add_argument('--key', required=True)
parser.add_argument('--log', required=True)
args = parser.parse_args()

class Handler(http.server.BaseHTTPRequestHandler):
    protocol_version = 'HTTP/1.1'
    def do_GET(self):
        sni = getattr(self.connection, 'voxfor_sni', '')
        host = self.headers.get('Host', '')
        body = f'listener={args.label} sni={sni} host={host} local_port={args.port}\n'.encode()
        with open(args.log, 'a', encoding='utf-8') as handle:
            handle.write(body.decode())
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain')
        self.send_header('Content-Length', str(len(body)))
        self.send_header('Connection', 'close')
        self.end_headers()
        self.wfile.write(body)
    def log_message(self, *_):
        return

server = http.server.ThreadingHTTPServer(('127.0.0.1', args.port), Handler)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(args.cert, args.key)
def remember_sni(sock, server_name, _context):
    sock.voxfor_sni = server_name or ''
context.set_servername_callback(remember_sni)
server.socket = context.wrap_socket(server.socket, server_side=True)
server.serve_forever()
PY
start_listener() {
  local label=$1 port=$2 cert_name=$3
  python3 "$lab_root/tls_identity_server.py" --label "$label" --port "$port" \
    --cert "$lab_root/$cert_name.crt" --key "$lab_root/$cert_name.key" \
    --log "$lab_root/$label.log" > "$lab_root/$label.stdout" 2>&1 &
  printf '%s\n' "$!" > "$lab_root/$label.pid"
}
start_listener front "$front_port" app.test
start_listener canary "$canary_port" app.test
start_listener wrong "$wrong_port" wrong.test
for label in front canary wrong; do
  pid=$(cat "$lab_root/$label.pid")
  kill -0 "$pid"
  grep -Fq "$lab_root/tls_identity_server.py" "/proc/$pid/cmdline"
done
for port in "$front_port" "$canary_port" "$wrong_port"; do
  for _ in {1..40}; do
    ss -lnt "sport = :$port" | grep -q LISTEN && break
    sleep 0.05
  done
  ss -lnt "sport = :$port" | grep -q LISTEN
done
printf 'listeners=front:%s,canary:%s,wrong:%s loopback_only=yes\n' \
  "$front_port" "$canary_port" "$wrong_port"

No response is accepted yet. Listener existence proves only that the fixture is ready to receive the option comparison.

Hold the URL Constant, Then Move the Socket

First create the --resolve control. The URL contains app.test:28443, the mapping is keyed to the same pair, and the supplied address is 127.0.0.1. --noproxy '*' keeps this local experiment out of environment-configured proxies.

resolve_body=$(curl --noproxy '*' --silent --show-error --fail \
  --cacert "$lab_root/ca.crt" \
  --resolve "app.test:$front_port:127.0.0.1" \
  "https://app.test:$front_port/health")
printf '%s\n' "$resolve_body" | tee "$lab_root/resolve.body"
grep -qx "listener=front sni=app.test host=app.test:$front_port local_port=$front_port" \
  "$lab_root/resolve.body"

Four layers must agree with the control: front listener, app.test SNI, app.test:28443 Host, and wire port 28443. A generic status 200 would be too weak because the wrong virtual host could also return 200.

Now keep the URL byte-for-byte identical and replace only the connection pair. The source fields match app.test:28443; the destination fields select loopback port 29443.

connect_body=$(curl --noproxy '*' --silent --show-error --fail \
  --cacert "$lab_root/ca.crt" \
  --connect-to "app.test:$front_port:127.0.0.1:$canary_port" \
  "https://app.test:$front_port/health")
printf '%s\n' "$connect_body" | tee "$lab_root/connect.body"
grep -qx "listener=canary sni=app.test host=app.test:$front_port local_port=$canary_port" \
  "$lab_root/connect.body"

This is the central proof. The canary reports local port 29443, while SNI remains app.test and Host remains app.test:28443. --connect-to changed where curl opened the socket; it did not rename the HTTPS request.

Backend hostnames work too. For example, --connect-to app.test:443:canary.internal:8443 tells curl to resolve canary.internal, connect to its port 8443, but continue validating the certificate for app.test. If reproducibility requires one exact canary address, make the destination a literal IP or add a separate mapping for the backend name.

Use Two Negative Controls to Expose False Equivalence

Changing the URL port can also reach the canary, but it tests a different authority. The next input makes that semantic change visible: SNI still uses the hostname, while the Host value now contains 29443 because that port is part of the URL.

url_port_body=$(curl --noproxy '*' --silent --show-error --fail \
  --cacert "$lab_root/ca.crt" \
  --resolve "app.test:$canary_port:127.0.0.1" \
  "https://app.test:$canary_port/health")
printf '%s\n' "$url_port_body" | tee "$lab_root/url-port.body"
grep -qx "listener=canary sni=app.test host=app.test:$canary_port local_port=$canary_port" \
  "$lab_root/url-port.body"

That may be the intended test when port 29443 is genuinely public. It is not equivalent to asking whether the production authority app.test:28443 works through a different internal listener. Redirect rules, cookies, absolute URLs, virtual-host selection, signature inputs, and application-generated links can all depend on authority.

Certificate verification checks a second shortcut. The connection goes to a listener presenting wrong.test, but the URL stays app.test:28443. curl must reject the certificate with exit 60. --connect-to does not—and must not—move certificate verification to the destination hostname.

set +e
curl --noproxy '*' --silent --show-error --fail \
  --cacert "$lab_root/ca.crt" \
  --connect-to "app.test:$front_port:127.0.0.1:$wrong_port" \
  "https://app.test:$front_port/health" \
  > "$lab_root/wrong-cert.body" 2> "$lab_root/wrong-cert.error"
wrong_rc=$?
set -e
test "$wrong_rc" -eq 60
grep -Eqi 'subject alternative name|no alternative certificate|certificate subject name' \
  "$lab_root/wrong-cert.error"
printf 'wrong_certificate=rejected curl_exit=%s url_identity=app.test\n' "$wrong_rc"

Do not add -k to make this control green. Disabling verification would remove the evidence that the selected backend can present a certificate valid for the real URL. When a renewed certificate is live on one address but stale on another, the separate live TLS endpoint mismatch workflow shows why each selected endpoint needs its own verified receipt.

Read the Receipt as a Four-Layer Contract

Finally, the tested input reasserts every exact body before printing the decision. It then checks each recorded process still belongs to this fixture, terminates only those PIDs, removes the owner-marked directory, and proves the path is absent.

grep -qx "listener=front sni=app.test host=app.test:$front_port local_port=$front_port" \
  "$lab_root/resolve.body"
grep -qx "listener=canary sni=app.test host=app.test:$front_port local_port=$canary_port" \
  "$lab_root/connect.body"
grep -qx "listener=canary sni=app.test host=app.test:$canary_port local_port=$canary_port" \
  "$lab_root/url-port.body"
printf 'decision=resolve_keeps_source_port connect_to_changes_wire_port\n'
printf 'resolve=wire:%s,sni:app.test,host:app.test:%s\n' "$front_port" "$front_port"
printf 'connect_to=wire:%s,sni:app.test,host:app.test:%s\n' "$canary_port" "$front_port"
printf 'changed_url=wire:%s,sni:app.test,host:app.test:%s\n' "$canary_port" "$canary_port"
printf 'certificate_control=wrong_san_rejected_exit_60\n'
grep -qx 'voxfor-curl-route-172' "$lab_root/.owner-marker"
for label in front canary wrong; do
  pid=$(cat "$lab_root/$label.pid")
  grep -Fq "$lab_root/tls_identity_server.py" "/proc/$pid/cmdline"
  kill "$pid"
done
for label in front canary wrong; do
  pid=$(cat "$lab_root/$label.pid")
  wait "$pid" 2>/dev/null || true
done
rm -rf --one-file-system "$lab_root"
test ! -e "$lab_root"
printf 'cleanup_scope=%s absent=yes\n' "$lab_root"

Representative output from the complete reproduced sequence:

listener=front sni=app.test host=app.test:28443 local_port=28443
listener=canary sni=app.test host=app.test:28443 local_port=29443
listener=canary sni=app.test host=app.test:29443 local_port=29443
wrong_certificate=rejected curl_exit=60 url_identity=app.test
decision=resolve_keeps_source_port connect_to_changes_wire_port
resolve=wire:28443,sni:app.test,host:app.test:28443
connect_to=wire:29443,sni:app.test,host:app.test:28443
changed_url=wire:29443,sni:app.test,host:app.test:29443
certificate_control=wrong_san_rejected_exit_60
cleanup_scope=/tmp/voxfor-curl-route-172 absent=yes

The comparison is successful when the --resolve control reaches wire port 28443, --connect-to reaches 29443 with the same app.test SNI and app.test:28443 Host, the changed-URL control exposes app.test:29443, the wrong certificate is rejected with curl exit 60, and the exact marker-owned cleanup path is absent. A production receipt should replace the listener body with an application-specific assertion and also record %{remote_ip}, %{remote_port}, HTTP status, TLS certificate identity, timestamp, client version, and selected URL.

Scope boundary: this output proves only the reproduced client request. It does not prove that public DNS has converged, every load-balancer node has the same certificate, a forward proxy selected the same endpoint, or an application workflow beyond /health is correct.

Carry the Option Into a Production Preflight

One tested command should represent one deployment hypothesis. Freeze the production URL, path, method, expected response, destination address or backend name, destination port, certificate trust source, and application assertion before running curl. Save verbose connection evidence or structured --write-out fields, but redact private topology before attaching the receipt to a public ticket.

What the receipt can and cannot approve

For a DNS cutover, --resolve is usually the clearest preflight because it binds the public authority to a chosen IP without changing /etc/hosts. Mirego’s CloudFront migration example shows that exact use. After DNS changes, however, resolver caches and negative answers follow their own timers; use DNS negative-cache diagnosis instead of treating the old curl mapping as proof of public convergence.

For a load balancer or reverse proxy, --connect-to is valuable when the public authority must reach a named backend or a non-public port. A successful application response still does not explain why a health checker marks the member down. Continue with the HAProxy probe-versus-service workflow when method, path, Host, timing, or expected status differs between the curl request and the configured check.

Redirects need explicit scope. curl applies a rule only when a request’s current source host and port match it. If --location follows another authority, add a second rule only after deciding that the redirected host belongs in the test. Reusing a custom Host: header across redirects can send the wrong authority farther than intended.

Forward proxies change the connection owner. With an HTTP or HTTPS proxy, curl may connect to the proxy and ask it to resolve or tunnel to the target. Do not assume a local --resolve or --connect-to rule reaches the origin in the same way; inspect the proxy-specific behavior and test the actual production path. Reverse-proxy applications also use forwarded host and scheme values after transport succeeds. Voxfor’s Keycloak proxy-header recovery guide covers that later application boundary.

IPv6 literals require careful quoting and brackets in the applicable option syntax. More importantly, record %{remote_ip} and %{remote_port} rather than assuming the first A or AAAA answer won. Happy Eyeballs, backend DNS changes, and multi-address names can make a hostname-based --connect-to test select a different address on another machine or run.

If any endpoint, certificate, Host, status, or application assertion differs, stop the cutover and remove the temporary --resolve or --connect-to argument from the test command. This loopback lab terminates only PIDs whose /proc command line contains its owned server path, then deletes only /tmp/voxfor-curl-route-172 after its exact marker matches. If production DNS, a load-balancer member, or listener configuration was already changed, restore the documented previous target through that system’s change record; do not edit a global hosts file, disable TLS verification, clear unrelated caches, or kill an unfamiliar listener as rollback.

Keep protocol acceptance narrow too. A successful HTTPS/1.1 or HTTP/2 origin test does not prove QUIC is reachable; the strict HTTP/3 curl path explains why a fallback-capable request can hide that separate failure.

A durable production rule is simple: preserve the real URL identity, change only the network layer under test, and assert both where the socket landed and what identity the application observed. Remove the per-command override after the decision so tomorrow’s command cannot silently keep testing yesterday’s backend.

curl Routing Questions

Does --connect-to change TLS SNI?

No. curl still takes the SNI name and certificate-verification hostname from the URL. --connect-to changes the connection destination used under the hood. The reproduced request reached port 29443 while both SNI and HTTP Host remained tied to app.test:28443.

Can --resolve send the request to a different backend port?

Not by itself. Its port identifies the URL host-and-port entry whose address should be overridden. To keep https://app.test:443/ as the request authority while opening a socket to port 8443, use --connect-to app.test:443:BACKEND:8443.

Is an IP URL plus a custom Host header equivalent for HTTPS?

No. HTTP Host arrives after the TLS handshake. With an IP address in the URL, curl does not automatically send the desired DNS hostname as SNI or validate the certificate against it. Keep the intended hostname in the URL and override resolution or connection separately.

Do --resolve and --connect-to bypass a forward proxy?

Do not assume they do. A configured proxy can own DNS resolution, CONNECT tunneling, and the actual network socket. Confirm which process selects the destination and inspect the proxy route. The local loopback lab deliberately uses --noproxy '*' so it tests curl’s direct behavior only.

What happens to the mapping after an HTTP redirect?

Redirect matching applies only when the new request uses the rule’s source host and port. A same-authority redirect can keep using the rule; a redirect to another authority needs its own intentionally scoped rule. Avoid a blanket custom Host header across redirects because it can outlive the authority it was meant to test.

Should -k be used while testing a new origin?

No. -k or --insecure removes certificate-chain and hostname evidence, which is often the main reason to test the origin before a cutover. Install the correct test CA for private fixtures or use the production trust chain. The wrong-SAN control should remain a failure.

Share this Post

Leave a Reply

Your email address will not be published. Required fields are marked *