The frontend opens at https://fuzzy-space-guide-7x9q-3000.app.github.dev, but login redirects to http://localhost:3000/callback and fails with redirect_uri_mismatch; API calls from the page are blocked with Access to fetch at 'http://localhost:8080/api' has been blocked by CORS policy; and the product manager who was sent the preview link gets a GitHub sign-in page instead of the app. Every one of these comes from the application assuming it lives at localhost. This page makes an app behave correctly behind a cloud workspace's port forwarding and shares previews deliberately, as part of cloud development environments for onboarding.

The examples use GitHub Codespaces variable names; Coder, DevPod and other platforms expose equivalent information with different names.

Diagnostic

Print what the workspace knows about its own public URLs and what the application thinks its URLs are:

#!/usr/bin/env bash
set -euo pipefail
echo "codespace: ${CODESPACE_NAME:-not in a codespace}"
echo "forwarding domain: ${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-unset}"
gh codespace ports -c "$CODESPACE_NAME" 2>/dev/null || true
grep -rnE 'localhost:(3000|8080)' .env* src/config 2>/dev/null | head -5 || true
curl -sS -o /dev/null -w 'api from workspace: %{http_code}\n' http://localhost:8080/health

Expected bad output:

codespace: fuzzy-space-guide-7x9q
forwarding domain: app.github.dev
LABEL  PORT  VISIBILITY  BROWSE URL
web    3000  private     https://fuzzy-space-guide-7x9q-3000.app.github.dev
api    8080  private     https://fuzzy-space-guide-7x9q-8080.app.github.dev
.env:3:PUBLIC_API_URL=http://localhost:8080
.env:4:OAUTH_REDIRECT_URI=http://localhost:3000/callback
api from workspace: 200

The API is healthy from inside the workspace, but the browser-facing configuration still points at localhost, which from the reviewer's browser is their own machine.

localhost vs Forwarded URL Comparison of what localhost and the forwarded HTTPS URL mean for the workspace and for the browser. localhost vs Forwarded URL http://localhost:8080 https://…-8080.app.github.dev works inside the workspace works in any browser browser: its own machine browser: the workspace plain HTTP HTTPS with auth gate
Server-to-server calls keep internal names; anything the browser sees must use the forwarded URL.

Root cause

A cloud workspace publishes ports through the platform's forwarding service, which gives each port an HTTPS URL on its own domain and, by default, requires the viewer to be authenticated as the workspace owner. Code that runs server-side inside the workspace can keep using localhost or Compose service names. But anything sent to the browser — API base URLs embedded in the frontend bundle, OAuth redirect URIs, CORS allowed origins, cookie domains, absolute links in emails — must use the forwarded URL, because the browser is not inside the workspace. Applications usually read these from environment variables with localhost defaults, and those defaults are exactly what a CDE breaks. The sign-in wall for the reviewer is a separate issue: port visibility defaults to private, which is correct until someone needs to share.

Resolution

  1. Derive public URLs from the workspace's environment in one place, with localhost as the fallback for local development:
#!/usr/bin/env bash
set -euo pipefail
public_url() {
  local port="$1" local_default="$2"
  if [ -n "${CODESPACE_NAME:-}" ]; then
    printf 'https://%s-%s.%s' "$CODESPACE_NAME" "$port" "$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN"
  else
    printf '%s' "$local_default"
  fi
}
cat > .env.workspace <<EOF
PUBLIC_WEB_URL=$(public_url 3000 https://app.localhost)
PUBLIC_API_URL=$(public_url 8080 https://api.localhost)
OAUTH_REDIRECT_URI=$(public_url 3000 https://app.localhost)/callback
CORS_ALLOWED_ORIGINS=$(public_url 3000 https://app.localhost)
EOF
cat .env.workspace

Run it from postStartCommand so the file is regenerated whenever the workspace starts, and load it after the base .env.

  1. Register the forwarding pattern with OAuth providers. Most development OAuth apps allow several redirect URIs; add the workspace pattern alongside the local one. Where wildcards are not supported, route login through a small development proxy with a fixed URL, or use a provider's test mode that accepts any HTTPS redirect.

  2. Make the API accept the forwarded origin and send credentials correctly:

const cors = require('cors');
const allowed = (process.env.CORS_ALLOWED_ORIGINS || '').split(',').filter(Boolean);
module.exports = cors({
  origin: (origin, cb) => cb(null, !origin || allowed.includes(origin)),
  credentials: true,
});

Because the web and API ports have different hostnames under the forwarding domain, cookies set by the API are third-party from the web page's perspective. The simplest fix is to serve the API under the web origin with a dev-server proxy (/apihttp://localhost:8080), so the browser only ever talks to one forwarded URL.

  1. Share previews with an explicit visibility change, and revert it afterwards:
#!/usr/bin/env bash
set -euo pipefail
gh codespace ports visibility 3000:org -c "$CODESPACE_NAME"
echo "share: https://${CODESPACE_NAME}-3000.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}"
# after the review:
# gh codespace ports visibility 3000:private -c "$CODESPACE_NAME"

org visibility lets anyone signed in to the organisation open the URL; public removes authentication entirely and should be used only briefly, for example to receive a webhook.

One Origin for the Browser Flow of browser requests to the forwarded web URL, with the dev server proxying API calls to the internal port. One Origin for the Browser browser forwarded :3000 web dev server /api proxied api localhost:8080 response same origin
Proxying /api through the web origin removes CORS and third-party cookie problems.

Expected output

$ cat .env.workspace
PUBLIC_WEB_URL=https://fuzzy-space-guide-7x9q-3000.app.github.dev
PUBLIC_API_URL=https://fuzzy-space-guide-7x9q-8080.app.github.dev
OAUTH_REDIRECT_URI=https://fuzzy-space-guide-7x9q-3000.app.github.dev/callback
CORS_ALLOWED_ORIGINS=https://fuzzy-space-guide-7x9q-3000.app.github.dev
$ gh codespace ports -c "$CODESPACE_NAME" | grep web
web    3000  org         https://fuzzy-space-guide-7x9q-3000.app.github.dev

Login completes and redirects back to the forwarded URL, API calls succeed without CORS errors, and colleagues in the organisation can open the preview after signing in.

The same script produces app.localhost URLs on a laptop, so the application code has no workspace-specific branches at all — it simply reads its public URLs from configuration, as it would in staging and production. That is the property to check for in review: if a change introduces a CODESPACE_NAME check anywhere outside the URL-generation script, the abstraction has leaked.

Prevention

  1. Ban hard-coded localhost in browser-facing configuration with a CI grep over frontend config and env templates, and require public URLs to come from variables.

  2. Test login in a workspace in CI. A scheduled job that creates a codespace, runs the end-to-end login test against the forwarded URL and deletes it catches redirect regressions.

  3. Default ports to private in devcontainer.json with portsAttributes and make sharing an explicit, temporary step.

What Must Use the Forwarded URL Table listing which configuration values must use the forwarded URL and which can stay internal. What Must Use the Forwarded URL Setting Seen by Use API base in bundle browser forwarded URL OAuth redirect URI provider, browser forwarded URL CORS origins browser forwarded URL DATABASE_URL server only service name
Anything the browser or a third party sees uses the public URL; internal calls do not.

Platform caveats

Desktop VS Code connected to a codespace: ports are also forwarded to the laptop's localhost, so http://localhost:3000 works on the developer's own machine. That masks the problem until someone else opens the link; test with the HTTPS URL.

Coder: forwarded apps are defined as coder_app resources in the template, with URLs of the form https://<app>--<workspace>--<user>.coder.corp.example when wildcard subdomains are configured. Use the same derive-from-environment approach with Coder's variables.

Webhooks: third-party services cannot authenticate to a private forwarded port. Use public visibility for the duration of the test, or a tunnelling approach described in simulating Stripe and GitHub webhooks locally.

Rollback

Return ports to private and remove the generated file; local development falls back to its .localhost defaults:

#!/usr/bin/env bash
set -euo pipefail
gh codespace ports visibility 3000:private 8080:private -c "$CODESPACE_NAME"
rm -f .env.workspace

Frequently Asked Questions

Why does OAuth login fail in a codespace but work locally?

The redirect URI sent to the provider is still http://localhost:3000/callback, which is not registered for the workspace URL and would point at the reviewer's own machine anyway. Derive the redirect URI from the workspace variables and register the forwarding pattern with the provider.

Why are API calls blocked by CORS in the workspace?

The web and API ports have different forwarded hostnames, so browser requests between them are cross-origin. Allow the web URL in the API's CORS configuration, or proxy /api through the web dev server so everything is same-origin.

Is it safe to make a port public?

Only briefly and for non-sensitive data. A public port is reachable by anyone with the URL and bypasses authentication. Prefer organisation visibility for reviews and switch back to private afterwards.

Do forwarded URLs change between workspaces?

Yes. Each workspace has its own name, so URLs are unique per workspace. That is why they must be generated at start rather than stored in committed configuration.