How to automatically inject multi-app endpoints as environment variables
Many projects deployed on Upsun contain multiple applications. For example, you might have a frontend application and a separate backend API, or several microservices working together.
Upsun lets you create relationships between your applications, similar to how you connect services like databases. However, these relationships are mainly for server-side communication over the internal network. Sometimes, one application needs the public hostname, or Uniform Resource Identifier (URI), of another application in the same project.
For instance, imagine a Next.js frontend application. It might need to call your API application from both server components (running on the server) and client components (running in the user’s browser). To do this, the frontend needs the API’s public URI.
Every application container deployed on Upsun automatically gets an environment variable called PLATFORM_ROUTES
. This variable contains information about all the project’s routes. This guide uses the following example project structure:
Define route identifiers
First, define your application routes in your project’s routing configuration, within .upsun/config.yaml
. Here’s a basic example:
routes:
"https://api.{all}/":
type: upstream
upstream: "api:http"
"https://{all}/":
type: upstream
upstream: "next:http"
To easily reference these routes later, add an id
attribute to each one. Choose a meaningful string for the ID. Using the application name often works well:
|
|
Read the full Upsun documentation on Route identifiers.
Fetch specific URIs from $PLATFORM_ROUTES
After deploying these changes, connect to one of your application containers using the Upsun CLI. For example, use upsun ssh -A api
where api
is your application’s name. Inside the container, you can view the PLATFORM_ROUTES
variable:
web@api.0:~$ echo $PLATFORM_ROUTES
eyJodHRwOi8vYXBpLm1haW4tYnZ4ZWE2aS1yY...........
The output is a base64 encoded JSON string. To see the actual JSON content, you need to decode it:
web@api.0:~$ echo $PLATFORM_ROUTES | base64 --decode
{"http://api.main-bvxea6i-abcdefgh.ca-1.platformsh.site/": {"id": null, "original_url": "http://api.{all}/", "primary": false, "production_url": "http://api.main-bvxea6i-abcdefgh.ca-1.platformsh.site/", "to": "https://api.main-bvxea6i-abcdefgh.ca-1.platformsh.site/", "type": "redirect"}, "http://main-bvxea6i-abcdefgh.ca-1.platformsh.site/": {"attributes": {}, "id": null, "original_url": "http://{all}/", "primary": false, "production_url": "http://main-bvxea6i-abcdefgh.ca-1.platformsh.site/", "to": "https://main-bvxea6i-abcdefgh.ca-1.platformsh.site/", "type": "redirect"}, "https://api.main-bvxea6i-abcdefgh.ca-1.platformsh.site/": {"attributes": {}, "id": "api", "original_url": "https://api.{all}/", "primary": true, "production_url": "https://api.main-bvxea6i-abcdefgh.ca-1.platformsh.site/", "type": "upstream", "upstream": "api"}, "https://main-bvxea6i-abcdefgh.ca-1.platformsh.site/": {"attributes": {}, "id": "next", "original_url": "https://{all}/", "primary": false, "production_url": "https://main-bvxea6i-abcdefgh.ca-1.platformsh.site/", "type": "upstream", "upstream": "next"}}
You can make this JSON easier to read by piping it through jq
. jq
is a command-line tool for processing JSON data. It helps you parse, filter, and transform JSON, which is useful when working with APIs and configuration files. You can find more information on the jq website.
web@api.0:~$ echo $PLATFORM_ROUTES | base64 --decode | jq
{
"http://api.main-bvxea6i-abcdefgh.ca-1.platformsh.site/": {
"id": null,
"original_url": "http://api.{all}/",
"primary": false,
"production_url": "http://api.main-bvxea6i-abcdefgh.ca-1.platformsh.site/",
"to": "https://api.main-bvxea6i-abcdefgh.ca-1.platformsh.site/",
"type": "redirect"
},
"http://main-bvxea6i-abcdefgh.ca-1.platformsh.site/": {
"attributes": {},
...
},
"https://api.main-bvxea6i-abcdefgh.ca-1.platformsh.site/": {
"attributes": {},
"id": "api",
...
},
"https://main-bvxea6i-abcdefgh.ca-1.platformsh.site/": {
"attributes": {},
"id": "next",
...
}
}
That’s much better!
Now, you need a jq
query to extract the specific URI you want, using the route id
defined earlier. Here’s how you can select the URI associated with the id
“api”:
web@api.0:~$ echo $PLATFORM_ROUTES | base64 --decode | jq -r 'to_entries[] | select(.value.id=="api") | .key'
https://api.main-bvxea6i-abcdefgh.ca-1.platformsh.site/
Here’s how that jq
query works:
(You can experiment with this query and the example JSON using the online jq playground.)
to_entries[]
: Converts the JSON object into an array where each item has akey
(the URI) and avalue
(the route details object). The[]
processes each item in the array.select(.value.id=="api")
: Filters these items, keeping only those where theid
field inside thevalue
object equals"api"
. Change"api"
to the route ID you need..key
: Extracts thekey
field (the URI) from the filtered item.- The
-r
flag tellsjq
to output the raw value instead of its JSON representation.
Now you know how to get a specific URI. The next step is making this URI available to your applications as an environment variable.
Set up environment variables using .environment
Upsun lets you define environment variables dynamically during the build process. You do this using a special script file named .environment
placed in your application’s source code directory. Find more details in the documentation on setting variables via script.
Suppose your Next.js application needs an API_HOST
variable with the API’s URI. And your API application needs a NEXT_PUBLIC_BASE_URL
variable with the frontend’s URI. You can create a .environment
file using the jq
queries from the previous step.
.environment
file in each application’s source directory.# This file runs during deployment to set environment variables.
# Place it in the root directory of applications that need these variables.
# Export the API host URI (identified by id: api)
export API_HOST=$(echo $PLATFORM_ROUTES | base64 --decode | jq -r 'to_entries[] | select(.value.id=="api") | .key')
# Export the Next.js frontend URI (identified by id: next)
# NEXT_PUBLIC_BASE_URL is a common convention for Next.js public variables.
export NEXT_PUBLIC_BASE_URL=$(echo $PLATFORM_ROUTES | base64 --decode | jq -r 'to_entries[] | select(.value.id=="next") | .key')
Commit the .environment
file(s) to your repository and push the changes to Upsun. After the deployment finishes, connect to an application container again and check the variables:
web@api.0:~$ echo $API_HOST
https://api.main-bvxea6i-abcdefgh.ca-1.platformsh.site/
web@api.0:~$ echo $NEXT_PUBLIC_BASE_URL
https://main-bvxea6i-abcdefgh.ca-1.platformsh.site/
Your applications can now access these URIs directly through the environment variables:
# In PHP
echo getenv('API_HOST');
# In Node.js
console.log(process.env.API_HOST);
# In Python
import os
print(os.environ.get("API_HOST"))
Bonus tip: Use these variables in statically exported JavaScript applications
JavaScript applications that are statically exported (like some Next.js static exports or Create React App builds) generate their assets and configuration during a build phase.
The way most static generators work is that they will replace the process.env.*
variables at build time with their actual values. Let’s take an example with a variable called API_ENDPOINT
.
This API_ENDPOINT
variable has a value of https://api.example.com
in either your .env
file or the server environment variable. Your Javascript code refers to that variable with process.env.API_ENDPOINT
.
When you are triggering the generation of the static export with npm run build
, the process.env.API_ENDPOINT
get replaced in the generated code with the actual value https://api.example.com
.
While it does seem to be a satisfactory way of doing it, it creates a problem when that code needs to run on multiple environments where that variable could have different values.
On production
, you might want to query https://api.example.com
but staging
should target ``https://staging.api.example.com`.
“Hard-coding” the value during prevent us from running the exact same build on different environments with different configurations make the build process undertiministic. Upsun’s goal is to run the exact same applications builds on all environment to guarantee the exact same behaviors during testing.
Injecting different variable values on different environments
In our example, the client application running in the user’s browser needs to be able to query a different API_ENDPOINT
based on the environment it is running in. Let’s explore two approaches.
Approach 1: Dynamically constructing the URI (If applicable)
The simplest approach, if your routes structure allows it, is to dynamically construct the necessary URI in the browser. For example, consider these routes again:
routes:
"https://api.{all}/":
type: upstream
upstream: "api:http"
"https://{all}/":
type: upstream
upstream: "next:http"
Here, the API endpoint (api.{all}
) is always on the same base domain as the frontend ({all}
), just prefixed with api.
. You could potentially use the browser’s current location.hostname
to figure out the API endpoint:
// Example function to get the API endpoint
export const getAPIEndpoint = (): string => {
// Check if running server-side (Node.js) where build-time env var might exist
// Ensure NEXT_PUBLIC_API_ENDPOINT is set during build if needed server-side
if (typeof window === 'undefined' && process.env.NEXT_PUBLIC_API_ENDPOINT) {
return process.env.NEXT_PUBLIC_API_ENDPOINT;
}
// Check if running in a browser
if (typeof window !== 'undefined') {
// Construct the API endpoint from the browser's hostname
return `https://api.${window.location.hostname}`;
}
// Fallback or error if environment is unknown
throw new Error("Cannot determine API endpoint: Unknown environment.");
};
However, this dynamic construction might not always work, especially if you don’t control the API endpoint naming structure or if the relationship between frontend and API hostnames is more complex.
Approach 2: Using a Deploy Hook and Runtime Configuration File
When dynamic construction isn’t suitable, the recommended alternative is to write the necessary environment-specific variables to a JSON configuration file during the deploy
hook. This hook runs after your application build is complete and deployed to the server, at which point it has access to runtime environment variables, services, and mounts.
Let’s assume your project uses an API_ENDPOINT
environment variable whose value changes per environment (e.g., defined using Upsun’s environment variables features).
During the deploy
hook, you can read this variable’s value and store it in a publicly accessible JSON file, for example, variables.json
.
First, ensure you have a writable mount defined in your application configuration where you can store this file:
# .upsun/app.yaml or .upsun/config.yaml depending on your setup
# Define a writable mount named 'storage'
mounts:
'storage':
source: storage # Use 'local:files/storage' for local storage
source_path: '' # Optional: subdirectory within the source
Next, make this storage location accessible via HTTP so the frontend JavaScript can fetch the file. Configure this in your web
settings:
# .upsun/app.yaml or .upsun/config.yaml
web:
locations:
# Make the 'storage' mount publicly readable at /storage
/storage:
root: "storage" # Corresponds to the mount name
passthru: true # Allow direct access to files
scripts: false
allow: true
expires: -1 # Disable caching or set appropriate headers
Note: Adjust passthru
, scripts
, allow
, and expires
based on your security and caching needs.
Finally, add the deploy
hook to write the environment variable into the JSON file within the mounted directory. The path /app/storage
typically corresponds to the storage
mount point inside the container:
# .upsun/app.yaml or .upsun/config.yaml
hooks:
deploy: |
# Write the environment variable to a JSON file
printf '{"API_ENDPOINT": "%s"}\\n' "$API_ENDPOINT" > /app/storage/variables.json
Your application can then fetch this /storage/variables.json
file at runtime using a library like axios
or the native fetch
API to get the correct API_ENDPOINT
for the current environment:
// Using native fetch API
fetch('/storage/variables.json')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json(); // Parses the JSON response body
})
.then(data => {
const apiEndpoint = data.API_ENDPOINT;
console.log('API Endpoint from fetch:', apiEndpoint);
// Use the apiEndpoint in your application
})
.catch(error => {
console.error('Error fetching config:', error);
});
// Using axios (assuming axios is installed and imported)
// import axios from 'axios';
axios.get('/storage/variables.json')
.then(response => {
const apiEndpoint = response.data.API_ENDPOINT; // axios automatically parses JSON
console.log('API Endpoint from axios:', apiEndpoint);
// Use the apiEndpoint in your application
})
.catch(error => {
console.error('Error fetching config with axios:', error);
});
Choose the method that best suits your project structure and needs. Both methods allows a single build artifact to be deployed across different environments, each retrieving its specific configuration at runtime.
This approach ensures your application build remains deterministic while allowing runtime flexibility for environment-specific settings like API endpoints.