diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index bddacf4a0862..9ae7aed1c51a 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -75,3 +75,5 @@ ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access # PG_SSL_ALLOW_SELF_SIGNED=true # SESSION_STORE_SECRET=replace_me_with_a_random_string_session # ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key +# SSL_KEY_PATH="./certs/your-cert.key" +# SSL_CERT_PATH="./certs/your-cert.crt" \ No newline at end of file diff --git a/packages/twenty-server/scripts/ssl-generation/README.md b/packages/twenty-server/scripts/ssl-generation/README.md new file mode 100644 index 000000000000..d444f0f87049 --- /dev/null +++ b/packages/twenty-server/scripts/ssl-generation/README.md @@ -0,0 +1,78 @@ +# Local SSL Certificate Generation Script + +This Bash script helps generate self-signed SSL certificates for local development. It uses OpenSSL to create a root certificate authority, a domain certificate, and configures them for local usage. + +## Features +- Generates a private key and root certificate. +- Creates a signed certificate for a specified domain. +- Adds the root certificate to the macOS keychain for trusted usage (macOS only). +- Customizable with default values for easier use. + +## Requirements +- OpenSSL + +## Usage + +### Running the Script + +To generate certificates using the default values: + +```sh +./script.sh +``` + +### Specifying Custom Values + +1. **Domain Name**: Specify the domain name for the certificate. Default is `localhost.com`. +2. **Root Certificate Name**: Specify a name for the root certificate. Default is `myRootCertificate`. +3. **Validity Days**: Specify the number of days the certificate is valid for. Default is `825` days. + +#### Examples: + +1. **Using Default Values**: + ```sh + ./script.sh + ``` + +2. **Custom Domain Name**: + ```sh + ./script.sh example.com + ``` + +3. **Custom Domain Name and Root Certificate Name**: + ```sh + ./script.sh example.com customRootCertificate + ``` + +4. **Custom Domain Name, Root Certificate Name, and Validity Days**: + ```sh + ./script.sh example.com customRootCertificate 1095 + ``` + +## Script Details + +1. **Check if OpenSSL is Installed**: Ensures OpenSSL is installed before executing. +2. **Create Directory for Certificates**: Uses `~/certs/{domain}`. +3. **Generate Root Certificate**: Creates a root private key and certificate. +4. **Add Root Certificate to macOS Keychain**: Adds root certificate to macOS trusted store (requires admin privileges). +5. **Generate Domain Key**: Produces a private key for the domain. +6. **Create CSR**: Generates a Certificate Signing Request for the domain. +7. **Generate Signed Certificate**: Signs the domain certificate with the root certificate. + +## Output Files + +The generated files are stored in `~/certs/{domain}`: + +- **Root certificate key**: `{root_cert_name}.key` +- **Root certificate**: `{root_cert_name}.pem` +- **Domain private key**: `{domain}.key` +- **Signed certificate**: `{domain}.crt` + +## Notes + +- If running on non-macOS systems, you'll need to manually add the root certificate to your trusted certificate store. +- Ensure that OpenSSL is installed and available in your PATH. + +## License + +This script is licensed under the [MIT License](LICENSE). \ No newline at end of file diff --git a/packages/twenty-server/scripts/ssl-generation/script.sh b/packages/twenty-server/scripts/ssl-generation/script.sh new file mode 100755 index 000000000000..94c8d438fd26 --- /dev/null +++ b/packages/twenty-server/scripts/ssl-generation/script.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +# Check if OpenSSL is installed +if ! command -v openssl &> /dev/null +then + echo "OpenSSL is not installed. Please install it before running this script." + exit +fi + +# Default values +DOMAIN=${1:-localhost.com} +ROOT_CERT_NAME=${2:-myRootCertificate} +VALIDITY_DAYS=${3:-825} # Default is 825 days + +CERTS_DIR=~/certs/$DOMAIN + +# Create a directory to store the certificates +mkdir -p $CERTS_DIR +cd $CERTS_DIR + +# Generate the private key for the Certificate Authority (CA) +openssl genrsa -des3 -out ${ROOT_CERT_NAME}.key 2048 + +# Generate the root certificate for the CA +openssl req -x509 -new -nodes -key ${ROOT_CERT_NAME}.key -sha256 -days $VALIDITY_DAYS -out ${ROOT_CERT_NAME}.pem \ + -subj "/C=US/ST=State/L=City/O=MyOrg/OU=MyUnit/CN=MyLocalCA" + +# Add the root certificate to the macOS keychain (requires admin password) +if [[ "$OSTYPE" == "darwin"* ]]; then + sudo security add-trusted-cert -d -r trustRoot -k "/Library/Keychains/System.keychain" ${ROOT_CERT_NAME}.pem +fi + +# Generate the private key for the provided domain +openssl genrsa -out $DOMAIN.key 2048 + +# Create a Certificate Signing Request (CSR) for the provided domain +openssl req -new -key $DOMAIN.key -out $DOMAIN.csr \ + -subj "/C=US/ST=State/L=City/O=MyOrg/OU=MyUnit/CN=*.$DOMAIN" + +# Create a configuration file for certificate extensions +cat > $DOMAIN.ext << EOF +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names + +[alt_names] +DNS.1 = $DOMAIN +DNS.2 = *.$DOMAIN +EOF + +# Sign the certificate with the CA +openssl x509 -req -in $DOMAIN.csr -CA ${ROOT_CERT_NAME}.pem -CAkey ${ROOT_CERT_NAME}.key -CAcreateserial \ + -out $DOMAIN.crt -days $VALIDITY_DAYS -sha256 -extfile $DOMAIN.ext + +echo "Certificates generated in the directory $CERTS_DIR:" +echo "- Root certificate: ${ROOT_CERT_NAME}.pem" +echo "- Domain private key: $DOMAIN.key" +echo "- Signed certificate: $DOMAIN.crt" + +# Tips for usage +echo "To use these certificates with a local server, configure your server to use $DOMAIN.crt and $DOMAIN.key." \ No newline at end of file diff --git a/packages/twenty-server/src/engine/api/rest/rest-api.service.ts b/packages/twenty-server/src/engine/api/rest/rest-api.service.ts index 128df3f96b33..782e7980ab7f 100644 --- a/packages/twenty-server/src/engine/api/rest/rest-api.service.ts +++ b/packages/twenty-server/src/engine/api/rest/rest-api.service.ts @@ -6,8 +6,8 @@ import { AxiosResponse } from 'axios'; import { Query } from 'src/engine/api/rest/core/types/query.type'; import { getServerUrl } from 'src/utils/get-server-url'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { RestApiException } from 'src/engine/api/rest/errors/RestApiException'; +import { ApiUrl } from 'src/engine/utils/server-and-api-urls'; export enum GraphqlApiType { CORE = 'core', @@ -16,16 +16,10 @@ export enum GraphqlApiType { @Injectable() export class RestApiService { - constructor( - private readonly environmentService: EnvironmentService, - private readonly httpService: HttpService, - ) {} + constructor(private readonly httpService: HttpService) {} async call(graphqlApiType: GraphqlApiType, request: Request, data: Query) { - const baseUrl = getServerUrl( - request, - this.environmentService.get('SERVER_URL'), - ); + const baseUrl = getServerUrl(request, ApiUrl.get()); let response: AxiosResponse; const url = `${baseUrl}/${ graphqlApiType === GraphqlApiType.CORE diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index e80b41c8737a..78f55c1e9a54 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -127,13 +127,20 @@ export class EnvironmentVariables { PG_SSL_ALLOW_SELF_SIGNED = false; // Frontend URL - @IsUrl({ require_tld: false }) + @IsUrl({ require_tld: false, require_protocol: true }) FRONT_BASE_URL: string; // Server URL - @IsUrl({ require_tld: false }) + // URL of the nodejs server + // use an SSL certificate to be compliant with security certifications + @IsUrl({ require_tld: false, require_protocol: true }) @IsOptional() - SERVER_URL: string; + SERVER_URL = 'http://localhost'; + + // URL of the API, differ from SERVER_URL if you use a proxy like a load balancer + @IsOptional() + @IsUrl({ require_tld: false, require_protocol: true }) + API_URL: string; @IsString() APP_SECRET: string; @@ -166,7 +173,7 @@ export class EnvironmentVariables { INVITATION_TOKEN_EXPIRES_IN = '30d'; // Auth - @IsUrl({ require_tld: false }) + @IsUrl({ require_tld: false, require_protocol: true }) @IsOptional() FRONT_AUTH_CALLBACK_URL: string; @@ -198,11 +205,11 @@ export class EnvironmentVariables { @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) AUTH_MICROSOFT_CLIENT_SECRET: string; - @IsUrl({ require_tld: false }) + @IsUrl({ require_tld: false, require_protocol: true }) @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) AUTH_MICROSOFT_CALLBACK_URL: string; - @IsUrl({ require_tld: false }) + @IsUrl({ require_tld: false, require_protocol: true }) @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) AUTH_MICROSOFT_APIS_CALLBACK_URL: string; @@ -219,7 +226,7 @@ export class EnvironmentVariables { @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) AUTH_GOOGLE_CLIENT_SECRET: string; - @IsUrl({ require_tld: false }) + @IsUrl({ require_tld: false, require_protocol: true }) @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) AUTH_GOOGLE_CALLBACK_URL: string; @@ -475,6 +482,15 @@ export class EnvironmentVariables { // milliseconds @CastToPositiveNumber() SERVERLESS_FUNCTION_EXEC_THROTTLE_TTL = 1000; + + // SSL + @IsString() + @ValidateIf((env) => env.SERVER_URL.startsWith('https')) + SSL_KEY_PATH: string; + + @IsString() + @ValidateIf((env) => env.SERVER_URL.startsWith('https')) + SSL_CERT_PATH: string; } export const validate = ( diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts index 7a2828d0f364..e9ceee6b47f2 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts @@ -4,7 +4,6 @@ import { Request } from 'express'; import { OpenAPIV3_1 } from 'openapi-types'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils'; import { computeMetadataSchemaComponents, @@ -38,20 +37,17 @@ import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metada import { capitalize } from 'src/utils/capitalize'; import { getServerUrl } from 'src/utils/get-server-url'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { ApiUrl } from 'src/engine/utils/server-and-api-urls'; @Injectable() export class OpenApiService { constructor( private readonly accessTokenService: AccessTokenService, - private readonly environmentService: EnvironmentService, private readonly objectMetadataService: ObjectMetadataService, ) {} async generateCoreSchema(request: Request): Promise { - const baseUrl = getServerUrl( - request, - this.environmentService.get('SERVER_URL'), - ); + const baseUrl = getServerUrl(request, ApiUrl.get()); const schema = baseSchema('core', baseUrl); @@ -121,10 +117,7 @@ export class OpenApiService { async generateMetaDataSchema( request: Request, ): Promise { - const baseUrl = getServerUrl( - request, - this.environmentService.get('SERVER_URL'), - ); + const baseUrl = getServerUrl(request, ApiUrl.get()); const schema = baseSchema('metadata', baseUrl); diff --git a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts index 7b2148d23000..f6a2172a6162 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts @@ -6,10 +6,6 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Issuer } from 'openid-client'; import { Repository } from 'typeorm'; -import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; -import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; -import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output'; @@ -28,6 +24,7 @@ import { WorkspaceSSOIdentityProvider, } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { ApiUrl } from 'src/engine/utils/server-and-api-urls'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -39,9 +36,6 @@ export class SSOService { private readonly workspaceSSOIdentityProviderRepository: Repository, @InjectRepository(User, 'core') private readonly userRepository: Repository, - private readonly environmentService: EnvironmentService, - @InjectCacheStorage(CacheStorageNamespace.EngineWorkspace) - private readonly cacheStorageService: CacheStorageService, ) {} private async isSSOEnabled(workspaceId: string) { @@ -189,7 +183,7 @@ export class SSOService { buildCallbackUrl( identityProvider: Pick, ) { - const callbackURL = new URL(this.environmentService.get('SERVER_URL')); + const callbackURL = new URL(ApiUrl.get()); callbackURL.pathname = `/auth/${identityProvider.type.toLowerCase()}/callback`; @@ -199,7 +193,11 @@ export class SSOService { buildIssuerURL( identityProvider: Pick, ) { - return `${this.environmentService.get('SERVER_URL')}/auth/${identityProvider.type.toLowerCase()}/login/${identityProvider.id}`; + const authorizationUrl = new URL(ApiUrl.get()); + + authorizationUrl.pathname = `/auth/${identityProvider.type.toLowerCase()}/login/${identityProvider.id}`; + + return authorizationUrl.toString(); } private isOIDCIdentityProvider( diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts index 5b05d0970284..02c1f289f42c 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts @@ -14,6 +14,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { ApiUrl } from 'src/engine/utils/server-and-api-urls'; import { WorkspaceInvitationService } from './workspace-invitation.service'; @@ -70,6 +71,7 @@ describe('WorkspaceInvitationService', () => { environmentService = module.get(EnvironmentService); emailService = module.get(EmailService); onboardingService = module.get(OnboardingService); + ApiUrl.set('http://localhost:3000'); }); it('should be defined', () => { diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index 0e1025e8e1fc..1144d1851d3a 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -28,6 +28,7 @@ import { WorkspaceInvitationExceptionCode, } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { ApiUrl } from 'src/engine/utils/server-and-api-urls'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -234,7 +235,7 @@ export class WorkspaceInvitationService { link: link.toString(), workspace: { name: workspace.displayName, logo: workspace.logo }, sender: { email: sender.email, firstName: sender.firstName }, - serverUrl: this.environmentService.get('SERVER_URL'), + serverUrl: ApiUrl.get(), }; const emailTemplate = SendInviteLinkEmail(emailData); diff --git a/packages/twenty-server/src/engine/utils/__tests__/server-and-api-urls.spec.ts b/packages/twenty-server/src/engine/utils/__tests__/server-and-api-urls.spec.ts new file mode 100644 index 000000000000..4fbc81d9c18c --- /dev/null +++ b/packages/twenty-server/src/engine/utils/__tests__/server-and-api-urls.spec.ts @@ -0,0 +1,56 @@ +import { ServerUrl, ApiUrl } from 'src/engine/utils/server-and-api-urls'; + +describe('ServerUrl', () => { + afterEach(() => { + // Reset the serverUrl after each test + ServerUrl.set(''); + }); + + test('should throw error when getting uninitialized ServerUrl', () => { + expect(() => ServerUrl.get()).toThrow( + 'ServerUrl is not initialized. Call set() first.', + ); + }); + + test('should set and get ServerUrl correctly', () => { + const url = 'http://localhost:3000'; + + ServerUrl.set(url); + expect(ServerUrl.get()).toBe(url); + }); +}); + +describe('ApiUrl', () => { + beforeEach(() => { + // Reset the ServerUrl and apiUrl before each test + ServerUrl.set(''); + ApiUrl.set(''); + }); + + test('should throw error when getting uninitialized ApiUrl', () => { + expect(() => ApiUrl.get()).toThrow( + 'apiUrl is not initialized. Call set() first.', + ); + }); + + test('should throw error when setting ApiUrl without initializing ServerUrl', () => { + expect(() => ApiUrl.set()).toThrow( + 'ServerUrl is not initialized. Call set() first.', + ); + }); + + test('should set and get ApiUrl correctly', () => { + const apiUrl = 'http://api.example.com'; + + ApiUrl.set(apiUrl); + expect(ApiUrl.get()).toBe(apiUrl); + }); + + test('should set ApiUrl to ServerUrl value if no argument is passed', () => { + const serverUrl = 'http://localhost:3000'; + + ServerUrl.set(serverUrl); + ApiUrl.set(); // Set without argument, it should use ServerUrl.get() + expect(ApiUrl.get()).toBe(serverUrl); + }); +}); diff --git a/packages/twenty-server/src/engine/utils/server-and-api-urls.ts b/packages/twenty-server/src/engine/utils/server-and-api-urls.ts new file mode 100644 index 000000000000..68f3f775e5d2 --- /dev/null +++ b/packages/twenty-server/src/engine/utils/server-and-api-urls.ts @@ -0,0 +1,37 @@ +// The url of the Server, should be exposed in a private network +const ServerUrl = (() => { + let serverUrl = ''; + + return { + get: () => { + if (serverUrl === '') { + throw new Error('ServerUrl is not initialized. Call set() first.'); + } + + return serverUrl; + }, + set: (url: string) => { + serverUrl = url; + }, + }; +})(); + +// The url of the API callable from the public network +const ApiUrl = (() => { + let apiUrl = ''; + + return { + get: () => { + if (apiUrl === '') { + throw new Error('apiUrl is not initialized. Call set() first.'); + } + + return apiUrl; + }, + set: (url: string = ServerUrl.get()) => { + apiUrl = url; + }, + }; +})(); + +export { ServerUrl, ApiUrl }; diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index 469845f41103..a2b7498c04e6 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -2,6 +2,8 @@ import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; +import fs from 'fs'; + import session from 'express-session'; import bytes from 'bytes'; import { useContainer } from 'class-validator'; @@ -17,6 +19,7 @@ import './instrument'; import { settings } from './engine/constants/settings'; import { generateFrontConfig } from './utils/generate-front-config'; +import { ServerUrl, ApiUrl } from './engine/utils/server-and-api-urls'; const bootstrap = async () => { const app = await NestFactory.create(AppModule, { @@ -24,10 +27,25 @@ const bootstrap = async () => { bufferLogs: process.env.LOGGER_IS_BUFFER_ENABLED === 'true', rawBody: true, snapshot: process.env.DEBUG_MODE === 'true', + ...(process.env.SERVER_URL && + process.env.SERVER_URL.startsWith('https') && + process.env.SSL_KEY_PATH && + process.env.SSL_CERT_PATH + ? { + httpsOptions: { + key: fs.readFileSync(process.env.SSL_KEY_PATH), + cert: fs.readFileSync(process.env.SSL_CERT_PATH), + }, + } + : {}), }); const logger = app.get(LoggerService); const environmentService = app.get(EnvironmentService); + const serverUrl = new URL(environmentService.get('SERVER_URL')); + + serverUrl.port = environmentService.get('PORT').toString(); + // TODO: Double check this as it's not working for now, it's going to be heplful for durable trees in twenty "orm" // // Apply context id strategy for durable trees // ContextIdFactory.apply(new AggregateByWorkspaceContextIdStrategy()); @@ -68,7 +86,17 @@ const bootstrap = async () => { app.use(session(getSessionStorageOptions(environmentService))); } - await app.listen(process.env.PORT ?? 3000); + await app.listen(serverUrl.port, serverUrl.hostname); + + const url = new URL(await app.getUrl()); + + // prevent ipv6 issue for redirectUri builder + url.hostname = url.hostname === '[::1]' ? 'localhost' : url.hostname; + + ServerUrl.set(url.toString()); + ApiUrl.set(environmentService.get('API_URL')); + + logger.log(`Application is running on: ${url.toString()}`, 'Server Info'); }; bootstrap(); diff --git a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx index 84efd01e71af..e79fc5d3b8cd 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx @@ -37,6 +37,7 @@ yarn command:prod cron:calendar:calendar-event-list-fetch ['REDIS_URL', 'redis://localhost:6379', 'Redis connection url'], ['FRONT_BASE_URL', 'http://localhost:3001', 'Url to the hosted frontend'], ['SERVER_URL', 'http://localhost:3000', 'Url to the hosted server'], + ['API_URL', 'http://my-load-balancer', 'Url to the public endpoint'], ['PORT', '3000', 'Port'], ['CACHE_STORAGE_TYPE', 'redis', 'Cache type (memory, redis...)'], ['CACHE_STORAGE_TTL', '3600 * 24 * 7', 'Cache TTL in seconds']