Forwarding Ports and Sharing Previews From Workspaces
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.
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
- Derive public URLs from the workspace's environment in one place, with
localhostas 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.
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.
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 (/api → http://localhost:8080), so the browser only ever talks to one forwarded URL.
- 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.
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
Ban hard-coded
localhostin browser-facing configuration with a CI grep over frontend config and env templates, and require public URLs to come from variables.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.
Default ports to private in
devcontainer.jsonwithportsAttributesand make sharing an explicit, temporary step.
Platform caveats
Desktop VS Code connected to a codespace: ports are also forwarded to the laptop's
localhost, sohttp://localhost:3000works 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_appresources in the template, with URLs of the formhttps://<app>--<workspace>--<user>.coder.corp.examplewhen 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
publicvisibility 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.