28 min read

Docker Compose HTTPS: Avoid Double TLS Tunnels

Separate local and public TLS, preserve proxy headers, and prevent redirect, cookie, callback, certificate, and container-routing failures.

Public and local TLS terminate at separate points before traffic reaches a Docker application.
Public HTTPS and local HTTPS are separate trust boundaries.
Developer Guides ยท Docker Compose HTTPS ยท Localtonet ยท 2026

Keep local certificate trust, public HTTPS, proxy identity, and container routing separate

A Docker Compose application can encounter HTTPS at two different trust boundaries: a locally trusted reverse proxy used on the developer machine, and the public HTTPS address presented by a tunnel. Treating those boundaries as if they were one connection often causes redirect loops, rejected cookies, callback mismatches, certificate errors, and unreachable containers. In this guide, we build a framework-neutral model for nginx or Caddy with locally trusted certificates, choose a canonical public origin, verify forwarded request information, and then expose the working stack with Localtonet.

๐Ÿ”’ Separate local and public TLS trust ๐ŸŒ Preserve external host and scheme โšก Diagnose redirects, cookies, callbacks, and routing

Understand the two HTTPS trust boundaries

Local HTTPS and public HTTPS solve related but distinct problems. Local HTTPS gives a browser or another local client a trusted encrypted connection to an address such as https://foo.test. Public HTTPS gives a remote browser, webhook sender, OAuth provider, or collaborator an internet-reachable HTTPS origin. A development stack can use either boundary independently, or it can use both.

Consider a typical Compose stack. The application process listens for HTTP inside a container. An nginx or Caddy container accepts local HTTPS, terminates TLS, and forwards an HTTP request to the application over the Compose network. Separately, a Localtonet client establishes an outbound connection to our relay and provides a public address for the selected local target. That public address remains available only while the selected device is connected and the tunnel is running.

These connections should not be mentally collapsed into one TLS session. A certificate trusted by your workstation for foo.test does not automatically have meaning at a public tunnel address. Likewise, the public certificate presented at a public HTTPS address does not make a local nginx or Caddy certificate trusted by containers, command-line clients, or another machine.

๐Ÿ–ฅ๏ธ Local browser boundary A browser connects to a development hostname on the workstation. nginx or Caddy can terminate locally trusted TLS and proxy the request to an application container.
๐Ÿ“ฆ Compose network boundary Containers normally reach one another through their Compose service names and container ports. A container's loopback address refers to that container, not to the host or a neighboring service.
๐ŸŒ Public tunnel boundary With Localtonet, the client connects outward to our relay. An HTTP tunnel provides a public address while forwarding traffic to the configured local IP address and port.
๐Ÿงญ Application identity boundary Redirects, generated links, secure cookies, callback validation, and origin checks depend on the application's understanding of the original external host and scheme.
Two TLS layers are not automatically a defect

A public HTTPS connection followed by a second HTTPS connection to a local reverse proxy can be a deliberate design. The trouble begins when the tunnel expects HTTP but reaches a TLS-only port, when the local certificate is not trusted by the connecting component, or when the application reconstructs the wrong external URL. The goal is not to prohibit multiple TLS segments. It is to make every segment explicit.

Choose the simplest topology that meets the test

Comparison of edge-terminated HTTPS and optional HTTPS termination at a local reverse proxy.
Use local TLS only when the test requires HTTPS inside the development environment.

Before editing Compose files or reverse-proxy settings, decide what behavior the test actually requires. If the only requirement is a public HTTPS URL for an application that already works over local HTTP, the simplest design is usually to point the HTTP tunnel at that HTTP listener. Public clients use HTTPS, while the local target remains HTTP.

Local HTTPS is still useful when developers need browser behavior that depends on a secure local context, when the stack is intended to resemble a production ingress arrangement, or when local code should operate with secure cookies without a special HTTP-only development branch. In that case, nginx or Caddy can remain the local entry point even if the public path targets a different listener.

A third option is to make the public path pass through the local reverse proxy. That can be appropriate when the proxy performs routing or application behavior that the public test must exercise. It also adds another certificate and protocol boundary, so it should be chosen intentionally rather than merely because port 443 happens to be published.

Topology Use it when Main concern
Public HTTPS to local HTTP application The goal is remote access and the application already serves HTTP correctly The application must still understand the original public scheme and host when it creates absolute URLs or enforces HTTPS
Local HTTPS proxy to local HTTP application Local browser tests require a secure context or production-like TLS termination The workstation must trust the local certificate authority, and the proxy must route to the correct Compose service
Public HTTPS through a local HTTPS proxy The public test must exercise proxy-specific routing or behavior Protocol selection, local certificate trust, server-name handling, and forwarded request identity must all agree
Separate local and public entry points Developers need local HTTPS, but the tunnel can use a simpler HTTP target Both entry points should resolve to one intentional application configuration or use clearly separated environment profiles

For many development stacks, the fourth pattern is the easiest to reason about. Keep local nginx or Caddy for https://foo.test, but let the public tunnel target a known HTTP listener. This avoids requiring the tunnel path to validate a workstation-only certificate while preserving local HTTPS for browser testing.

Do not point an HTTP target at a TLS-only port

An HTTP client sends a plain HTTP request. A TLS listener expects a TLS handshake before it can parse HTTP. If those protocols are crossed, the result may appear as a reset connection, a bad gateway, an empty response, or a TLS parsing error. Verify both the target port and the protocol spoken on it. If the current Localtonet dashboard offers an HTTPS-capable local-target mode for your selected tunnel type, follow the current dashboard and documentation for that mode rather than assuming that an HTTP target will negotiate TLS.

Prepare Docker Compose, local trust, and application settings

The exact files and image versions differ between projects, but a sound setup needs a small set of clearly identified components. Record these values before changing the stack:

  • The Compose service name for the application.
  • The port on which the application listens inside its container.
  • Whether the application listener speaks HTTP or HTTPS.
  • The host port, if any, published for local or tunnel access.
  • The local development hostname, such as foo.test.
  • The component responsible for local TLS termination.
  • The canonical public HTTPS origin used for remote callbacks and links.
  • The application's documented mechanism for trusting a reverse proxy.

Docker Desktop, or Docker Engine with the Docker Compose plugin, is required for the Compose workflow. A local HTTPS setup also needs a certificate and private key for the development hostname. A tool such as mkcert can create a local certificate authority, install it into the workstation trust store, and generate locally trusted development certificates. The documented initialization command is:

mkcert -install

Certificate generation syntax, package installation commands, and trust-store integration vary by operating system and current mkcert release. They are not reproduced here because the supplied evidence does not establish one command that is correct for every supported platform. Follow the current mkcert installation procedure for the developer workstation, generate a certificate covering the exact local hostname, and keep the resulting private key out of source control.

The local hostname must resolve to the machine accepting the connection. On systems that use /etc/hosts, a development-only mapping can look like this:

127.0.0.1 foo.test

Windows uses a different hosts-file location, and managed workstations may restrict edits. The important behavior is that foo.test resolves to the local interface where the published proxy port is listening. This hosts-file mapping affects only machines configured with it. It does not create public DNS and does not make the hostname reachable from the internet.

Protect the local certificate authority

A local CA private key can issue certificates trusted by machines on which that CA has been installed. Do not copy the CA key into a container image, commit it to a repository, or distribute it as a project convenience. Mount only the development certificate and private key needed by the local reverse proxy, restrict access to them, and use separate material for each developer environment where practical.

Configure the local HTTPS path without hiding the application protocol

In a conventional local HTTPS arrangement, nginx or Caddy listens for HTTPS and forwards to an HTTP application service. The proxy and application share a Compose network, so the proxy should address the upstream by its Compose service name and container port. It should not use localhost unless the application process actually runs in the same container.

Avoid hardcoding an IP address assigned to a container. Container addresses can change when services are recreated. Compose service discovery provides a stable name within the project network. For example, if a service is named app, the reverse proxy conceptually sends traffic to app at the application's internal listening port. Substitute the port documented by the actual application rather than copying an arbitrary example.

The local reverse proxy needs four categories of configuration:

  1. A listener for the chosen local hostname and HTTPS port.
  2. The certificate and private-key files generated for that hostname.
  3. An upstream destination using the application service name and its real container port.
  4. Request metadata that allows the application to determine the client-visible host and scheme.

nginx and Caddy express these settings differently, and their exact directives depend on the image version and desired features. This guide therefore does not present a supposedly universal configuration file. Validate the selected image's current syntax, mount locations, and reload behavior. The architecture, however, remains the same: TLS ends at the local proxy, then the proxy forwards HTTP to the named application service.

Before starting the stack, ask Docker Compose to render and validate the merged configuration:

docker compose config

Review the output for unintended port collisions, missing certificate mounts, incorrect relative paths, and variables that expanded to empty values. Then start the project and inspect its state:

docker compose up -d
docker compose ps

If a service exits or repeatedly restarts, inspect its logs instead of testing through every network layer at once:

docker compose logs app

Replace app with the real service name. Check the reverse-proxy service logs separately. A certificate-loading failure at the proxy and a connection refusal from the application are different faults and should not be debugged as one symptom.

Do not publish ports that are not needed

Containers on the same Compose network can communicate without publishing every service port to the host. Publish only the entry point that must be reached from the workstation or by the Localtonet client. If the local proxy is the only intended entry point, the application can often remain reachable only within the Compose network. If the tunnel intentionally targets the application directly, that application listener must be reachable from the device running our client.

The correct binding depends on where the Localtonet client runs. A client running on the Docker host can target a port published on that host. A client running in another environment must be able to route to the selected local IP and port. Do not assume that a container-only port is reachable from a host process merely because it appears under expose or in application documentation.

Verify one hop at a time before creating the tunnel

Four-stage check of containers, local HTTP, local HTTPS, and the public tunnel endpoint.
Confirm each local hop before testing the public endpoint.

A tunnel cannot repair an application that is already unreachable locally. Verify each hop from the inside out, keeping the hostname and protocol explicit.

1

Confirm that the application container is running

Use docker compose ps and the application logs. Confirm that the process listens on the port configured for the service and that it did not bind only to an unexpected interface.

2

Test the application protocol directly where possible

If an HTTP port is intentionally published to the host, request that local HTTP address. A successful direct request isolates the application from the certificate and reverse-proxy layers.

3

Verify local hostname resolution

Confirm that the development hostname resolves to the intended local address. If it resolves elsewhere, certificate and proxy debugging will be misleading because the request is reaching the wrong server.

4

Open the local HTTPS origin

Request the exact hostname covered by the certificate, such as https://foo.test. Confirm that the browser trusts the certificate and that the expected application response appears.

5

Inspect the application-visible request identity

Temporarily use an application diagnostic route or safe request logging to inspect the host, scheme, and relevant forwarded metadata. Remove or restrict diagnostics that could disclose headers, cookies, or credentials.

Certificate errors at this stage belong to the local boundary. Common causes include generating a certificate for a different hostname, failing to install the local CA into the client trust store, mounting the wrong files, or testing from a container that does not trust the workstation's local CA.

A browser on the workstation and a command inside a container do not necessarily use the same trust store. Installing a CA on the workstation does not automatically install it in every container image. Avoid disabling certificate verification as a permanent workaround. Either make the test over the intended HTTP container path, or deliberately provision the required trust only where the local HTTPS connection is genuinely necessary.

Expose the verified target with Localtonet

Once one local target works reliably, expose that target rather than changing several layers at the same time. With Localtonet, the client on the device establishes an outbound connection to our relay, so the workflow does not require inbound router port forwarding, a public IP address, firewall changes, or VPN setup.

For this use case, create an HTTP tunnel that points to the chosen local IP address and port. An HTTP tunnel can use a generated subdomain, a selected subdomain where supported, or a custom domain. Exact custom-domain DNS instructions and available relay locations must be checked in the current dashboard and documentation rather than hardcoded into the stack.

1

Install and run our client on the correct device

Run the Localtonet client on the Docker host or another device that can reach the selected local service. Test reachability from that device before proceeding.

2

Authenticate and select the device

Use the device-specific authentication token associated with that client. Treat the token as a secret and never place it in Compose files, screenshots, source control, logs, or this article's example configuration.

3

Select an available relay server

Choose from the server or region values currently presented by our dashboard. Availability can vary, so do not copy a server code from an unrelated environment.

4

Create the HTTP tunnel configuration

Enter the local IP address and port for the target that already passed local verification. If targeting plain HTTP, select the corresponding HTTP workflow. If targeting a local TLS listener, confirm the currently documented HTTPS local-target behavior before relying on it.

5

Start the tunnel

Creating a tunnel does not start it. Use the Start control and wait until the selected client and tunnel are connected before testing the assigned public address.

6

Test, then stop or delete it when finished

Request the assigned public URL from a separate client and exercise the required workflow. Stop the tunnel when remote access is no longer needed, or delete it if the configuration should not be retained.

The tunnel is available only while the selected device remains connected and the tunnel is running. For the current product workflow, consult our Localtonet documentation.

A public development URL is an internet exposure

Do not assume that an unadvertised address is private. Require application authentication where appropriate, use least-privilege test accounts, avoid production data, and remove debug endpoints that reveal configuration or request headers. Apply any relevant access controls available in the current application and Localtonet configuration, and stop the tunnel after the test.

Preserve the original host and scheme safely

Trusted proxy headers preserve the public host and HTTPS scheme for the local application.
Trusted forwarded headers keep redirects, cookies, and callback URLs aligned with the external origin.

TLS termination changes what the next hop observes. A remote browser may connect to an HTTPS URL, while the application receives HTTP from a reverse proxy. Without trustworthy metadata, the application can conclude that the request is insecure and redirect it to HTTPS even though the browser already used HTTPS. The next proxied request looks insecure again, creating a loop.

Reverse-proxy deployments commonly communicate the original request through the standardized Forwarded header or de facto headers such as X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-For. Header behavior differs between proxies and deployment paths. This article does not claim that every named header is emitted by every Localtonet tunnel configuration. Inspect the headers that actually reach the application and configure nginx, Caddy, and the application according to their current documentation.

The application must also know which proxy hops it is allowed to trust. Blindly trusting forwarded headers from arbitrary internet clients allows a caller to claim a false scheme, host, or source address. A safe configuration accepts proxy-supplied identity only from the known ingress path and replaces or sanitizes untrusted incoming values at the first trusted proxy.

Request property Why the application needs it Failure when it is wrong
External scheme HTTPS redirects, secure-cookie decisions, and absolute URL generation Redirect loops, HTTP callback URLs, or cookies missing the expected Secure attribute
External host Canonical links, callback validation, tenant routing, and origin checks Links to a container name, localhost, or the local development hostname
External port Correct absolute URL construction when a non-default port is genuinely used Callbacks and redirects containing an internal or unreachable port
Client address Auditing, abuse controls, and application policy where supported Every request appears to come from the proxy, or spoofed values are trusted

Test the entire proxy chain

When both Localtonet and a local reverse proxy are present, the application receives metadata after more than one hop. Do not assume that a header observed with local nginx is identical to one observed through the public URL. Compare both paths using controlled diagnostic output, and never return authorization headers, cookies, tokens, or complete environment variables in that output.

Frameworks use different names for trusted-proxy options, external URL settings, and forwarded-header middleware. Use the framework's documented mechanism instead of writing ad hoc string parsing. Set the trust boundary narrowly enough to match the actual deployment path.

Choose one canonical external origin for public workflows

An origin combines scheme, host, and port. For remote testing, the canonical external origin should be the exact public HTTPS origin assigned to or configured for the running tunnel. Store it in the application's supported external-URL setting if the application provides one. Do not derive a public callback from a container service name, localhost, or the local .test hostname.

Local and public origins can coexist, but they are not interchangeable. A local browser might use https://foo.test, while an OAuth provider or webhook sender must use the public HTTPS origin. Decide which origin owns each workflow and configure allowlists explicitly rather than accepting every host.

Secure cookies

The Secure cookie attribute instructs browsers to send that cookie only over a secure connection. Local HTTPS is useful because it lets a development browser exercise this production-like behavior. Through a public HTTPS tunnel, the browser also sees a secure origin, but the application may still receive an HTTP request from the final proxy hop. The application must either use a configured canonical HTTPS origin or correctly trust verified proxy metadata when deciding how to issue the cookie.

Other cookie properties remain relevant. A cookie scoped to foo.test will not be sent to a different public hostname. Cookie Domain, Path, expiration, and SameSite behavior should be reviewed independently. Do not weaken these settings globally merely to make one development callback succeed.

OAuth and sign-in callbacks

OAuth providers usually compare callback URLs against registered values. The callback generated by the application must match the public origin and expected path. A mismatch may arise because the application believes its host is localhost, because it sees HTTP instead of HTTPS, or because a stale tunnel URL remains in configuration.

When the public address changes, update the registered development callback and the application's external URL together. Do not add broad wildcard callbacks unless the identity provider explicitly supports them and the security consequences are acceptable.

Webhooks and generated links

A webhook sender outside the local network cannot call foo.test, a Compose service name, or a loopback address. Register the exact public URL, including its required path. Verify the endpoint from outside the development machine, then confirm that the application validates signatures or shared secrets according to the webhook provider's requirements.

Generated password-reset links, email verification links, API documentation links, and asset URLs can fail for the same reason. If they contain an internal host or local scheme, correct the application's canonical-origin or trusted-proxy configuration rather than rewriting each link separately.

Troubleshoot by symptom and network layer

The public URL returns a gateway error or resets the connection

First verify that the Localtonet client device can reach the exact configured IP address and port. Then identify the protocol on that port. If the tunnel sends HTTP to a TLS-only listener, or attempts a TLS connection to a plain HTTP listener, correct the mismatch. Also check whether the target service is bound only inside a container and has not been published or otherwise made reachable from the client device.

The reverse proxy cannot reach the application

Replace localhost in the proxy upstream with the Compose service name when the services run in separate containers. Confirm that both services share a network and that the proxy uses the application's container port, not a host-published port intended for workstation access. Review application logs to ensure the process is listening.

The local browser reports an untrusted certificate

Confirm that the certificate covers the exact hostname in the browser address bar. Verify that the local CA was installed into that browser or operating system's active trust store. Restarting the browser may be required after trust-store changes. If testing from a container, remember that the workstation's trust store does not automatically apply there.

The application redirects forever

Inspect the redirect's Location value and the request identity observed by the application. A common pattern is that the browser requests HTTPS while the application sees the proxy's HTTP connection and repeatedly redirects. Configure the documented trusted-proxy mechanism, preserve the external scheme, and ensure that untrusted clients cannot spoof the accepted metadata.

Redirects point to localhost, a service name, or the wrong hostname

Check the canonical external URL and forwarded host handling. The application should generate public links from the intentional external origin, not from its internal socket address. Also verify that nginx or Caddy is not replacing the external host with the upstream service name unless the application explicitly requires that behavior.

A secure cookie is set but never returned

Inspect the cookie's Domain, Path, Secure, and SameSite attributes in the browser. A cookie issued for the local hostname is not valid for the public hostname. Confirm that the login request and subsequent request use the same intended origin and that browser privacy rules are not rejecting the context.

An OAuth callback is rejected

Compare the complete callback URLs character by character: scheme, hostname, port, and path. Confirm that the current public address is registered with the provider and configured in the application. If the application generates an HTTP callback while the browser uses HTTPS, repair scheme reconstruction at the proxy boundary.

The site works locally but not through the tunnel

Test the local target from the exact device running our client, not only from a browser on another machine or from inside a container. Then start the tunnel and test the public URL. If basic requests work but redirects fail, focus on external-origin and forwarded-header configuration rather than Docker reachability.

The public page loads but WebSockets or live reload fail

Determine whether the application uses a separate port, host, or protocol for its live connection. The supplied Localtonet context does not establish universal WebSocket behavior or framework-specific hot-reload settings, so do not guess a tunnel option. Inspect the browser's network panel, identify the attempted public URL, and configure the framework and current tunnel workflow according to their documented capabilities.

Everything works only when certificate verification is disabled

That is evidence of an unresolved trust-boundary problem, not a finished solution. Identify which client is validating which certificate. For a local-only CA, install trust only in the clients that deliberately connect to the local TLS endpoint. If the tunnel does not need to traverse local TLS, target the verified HTTP listener instead of globally disabling certificate checks.

Operate the development exposure safely

Development tunnels should have a defined lifecycle. Start the Compose stack, verify the intended local target, start the tunnel, complete the external test, and then stop the tunnel. If a tunnel configuration is no longer required, delete it. Remember that creation and execution are separate actions in our dashboard.

Keep device tokens outside repositories and container images. Do not paste them into issue trackers or terminal recordings. Use test accounts and synthetic data for public workflows. Disable verbose request logging after diagnosis, especially when requests contain session cookies, OAuth codes, webhook signatures, or personal data.

Configuration changes should be made one boundary at a time. If a failing test involves Docker networking, local TLS, the public tunnel, and OAuth, first prove the HTTP application, then local proxying, then public reachability, then callback behavior. This sequence produces useful evidence at every stage and prevents one workaround from hiding a different fault.

Finally, document the chosen topology in the project itself. State which service owns local TLS, which local listener the tunnel targets, what the canonical public origin controls, and which proxy hops the application trusts. That small architecture note is often more valuable than a collection of unexplained environment variables.

Frequently asked questions

Do I need mkcert if Localtonet already provides a public HTTPS address?

Not necessarily. mkcert addresses trust for a local development hostname such as foo.test. The public tunnel address addresses remote internet access. If you only need public HTTPS and your application works over local HTTP, you can usually keep the local target on HTTP. Use mkcert when local browser testing itself requires trusted HTTPS.

Is double TLS termination always wrong?

No. Separate TLS segments can be intentional. They become problematic when a component connects with the wrong protocol, cannot validate the certificate presented by the next hop, or loses the original external host and scheme. Use the simplest topology that meets the test, and document every termination point.

Should the Localtonet tunnel target nginx, Caddy, or the application?

Target the component whose behavior the public test needs. If the reverse proxy performs required routing or policy, target that entry point using a currently documented compatible protocol. If it only provides local certificate trust, targeting the application's verified HTTP listener is usually simpler and avoids an unnecessary local certificate-validation boundary.

Why can a container not connect to a service at localhost?

Inside a container, localhost refers to that same container. Another Compose service should normally be reached by its service name over a shared Compose network. Reaching a service on the Docker host is platform-dependent, so use the host-access mechanism documented for the installed Docker platform rather than assuming one hostname works everywhere.

Why does HTTPS redirect back to itself forever?

The application probably sees an HTTP connection from the final proxy and does not recognize that the original client used HTTPS. Inspect the actual forwarded metadata, configure the application's documented trusted-proxy behavior, and restrict trust to the known ingress path. Also check for conflicting application-level and proxy-level redirect rules.

Can the local and public sites share the same cookies?

Usually not as one host-scoped cookie because the local and public hostnames differ. Browsers apply cookie domain, path, Secure, and SameSite rules to each request. Treat the local and public origins as distinct and configure the application's development authentication flow accordingly.

Does creating a Localtonet tunnel make it immediately available?

No. Creating a tunnel and running it are separate lifecycle actions. The selected client must be connected, and the tunnel must be started. The public address remains available only while the client is connected and the tunnel is running.

Can I use a custom public domain for the tunnel?

HTTP tunnels may use a generated subdomain, a selected subdomain where supported, or a custom domain. Exact DNS requirements and availability should be checked in the current Localtonet dashboard and documentation because they should not be inferred from a local Compose or mkcert configuration.

Expose the verified entry point with Localtonet

First prove the application and its chosen local listener, then create an HTTP tunnel for that exact IP address and port. Keep the public origin consistent across redirects, secure cookies, callbacks, and webhook registrations, and stop the tunnel when the external test is complete.

Get Started Free โ†’

Localtonet is a secure multi-protocol tunneling and proxy platform designed to expose localhost, devices, private services, and AI agents to the public internet supporting HTTP/HTTPS tunnels, TCP/UDP forwarding, mobile proxy infrastructure, file server publishing, latency-optimized game connectivity, and developer-ready AI agent endpoint exposure from a single unified control plane.

support