How to deploy a Vue.js Single Page Application (SPA) with a Golang API on Platform.sh

Goal

Deploy a Vue.js Single Page Application (SPA) with a Golang API backend on Platform.sh

Assumptions

Problems

Two strategies are possible when building an SPA with Platform.sh:

  • creating two separate projects for Vue.js frontend and Golang API backend
  • hosting both apps within a single multi-app Platform project (requires at least a Medium plan)

This How-to shows the second option.

Steps (Multi-app SPA)

1. Project structure

Ultimately, the project structure will look like the following (one common .platform directory, and one .platform.app.yaml file per app):

vuespa/
    .git/
    .platform/
        routes.yaml
        services.yaml
    hello_world_backend/
        .platform.app.yaml
        hello_world.go
    hello_world_frontend/
        .platform.app.yaml
        node_modules/
        public/
        src/
            App.vue
            main.js
        babel.config.js
        package-lock.json
        package.json

2. Set Up Golang App

1. Create the Go Project

Create and enter a project directory vuespa. Create a hello_world_backend directory, and then create the following hello_world.go file within that directory:

// vuespa/hello_world_backend/hello_world.go

package main

import (
	"encoding/json"
	"fmt"
	"net/http"

	psh "github.com/platformsh/gohelper"
	"github.com/rs/cors"
)

// Greetings is a basic Greetings to user
type Greetings struct {
	Message string `json:"message"`
}

// sayHello returns greetings to user in JSON format
func sayHello(w http.ResponseWriter, r *http.Request) {

	greetings := Greetings{Message: "Hello World!"}

	returnedJSON, err := json.Marshal(greetings)
	if err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}

	w.Header().Set("Content-Type", "application/json")
	fmt.Fprintf(w, "%s", returnedJSON)
}

func main() {

	// Load Platform.sh environment variables in order to retrieve correct port
	p, err := psh.NewPlatformInfo()
	if err != nil {
		panic("Not in a Platform.sh Environment.")
	}

	// Initialize HTTP server
	mux := http.NewServeMux()

	// Set up the /say-hello API endpoint
	mux.HandleFunc("/say-hello", sayHello)

	// Enable CORS and Whitelist the frontend domain in order to comply with
	// CORS Allowed Origins policy
	handler := cors.New(cors.Options{
		AllowedOrigins: []string{"https://your-platformsh-frontend-url"},
	}).Handler(mux)

	// Launch HTTP server with custom port retrieved in Platform.sh env var
	http.ListenAndServe(":"+p.Port, handler)
}

The above exposes a say-hello REST endpoint returning a greetings message to user.

CORS need to be properly handled as both frontend and backend apps are not hosted at the same URLs. Ideally the frontend URL whitelisted in CORS (https://your-platformsh-frontend-url) should be retrieved through an environment variable.

For more information about CORS please visit: https://wikipedia.org/wiki/Cross-origin_resource_sharing

The frontend url will not be made available until the project is pushed to Platform.sh for the first time, so it may be necessary to push the code once to establish those routes, and then commit the url once they are defined.

In general, they will take the forms https://https://master-7rqtwti-<project ID>.<project region>.platformsh.site for the frontend url and https://https://backend.master-7rqtwti-<project ID>.<project region>.platformsh.site for the backend url.

2. Set up the Platform.sh configuration

Create a .platform.app.yaml within the directory:

# vuespa/hello_world_backend/.platform.app.yaml

name: go-backend

# Use the Golang 1.12 image
type: golang:1.12

# A Medium plan is necessary for multi-app
size: M

hooks:
  # Get dependencies and build Go app
  build: |
    go get ./...
    go build -o bin/app

web:
  upstream:
    socket_family: tcp
    protocol: http

  # Launch the Go server
  commands:
    start: ./bin/app

  locations:
    /:
      allow: false
      passthru: true

disk: 1024

3. Set Up Vue.js App

1. Initialize the Vue.js project

Create the Vue.js base project with the Vue CLI, selecting the default install option when prompted:

$ vue create hello_world_frontend

cd into hello_world_frontend and install the axios dependency:

$ npm install axios

2. Update The Vue.js Project

Remove everything inside the src directory except the main.js file and add the following App.vue file:

<!-- vuespa/hello_world_frontend/src/App.vue -->

<template>
  <div id="app">
    <p v-if="msg">
      Retrieved the following greetings message from API Go backend:
      <b>{{ msg }}</b>
    </p>
    <p v-if="msgError">
      Error while getting message from Go API backend:
      <b>{{ msgError }}</b>
    </p>
  </div>
</template>

<script>
import axios from 'axios'
export default {
  name: 'app',
  data() {
    return {
      msg: '',
      msgError: '',
    }
  },
  mounted: function () {
    axios.get('https://your-platformsh-backend-url/say-hello')
      .then(response => {
        if (response.status === 200) {
          if (response.data) {
            this.msg = response.data.message
          } else {
            this.msgError = 'Got no data from API'
          }
        } else {
          this.msgError = 'Got an ok but unhandled HTTP response from API'
        }
      })
      .catch(e => {
        if (e.response) {
          if (e.response.status === 500) {
            this.msgError = 'Got an internal server error from API'
          } else {
            this.msgError = 'Got an unhandled error HTTP response from API'
          }
        } else if (e.request) {
          this.msgError = 'Got no response when contacting API server'
        } else {
          this.msgError = 'Got an unhandled error when contacting API server'
        }
      })

  }
}
</script>

3. Set up the Platform.sh configuration

Now create a .platform.app.yaml with the following:

# vuespa/hello_world_frontend/.platform.app.yaml

name: vuejs-frontend

type: nodejs:10

# A Medium plan is necessary for multi-app
size: M

hooks:
  build: |
    npm install
    npm run build

# There is no need for a writable file mount, so set it to the smallest possible size.
disk: 256

web:
  commands:
    # Run a no-op process that uses no CPU resources, since this is a static site.
    start: sleep infinity
  locations:
    "/":
      root: "dist"
      index:
        - "index.html"
      # Set a 5 minute cache lifetime on all static files.
      expires: 300s
      # Disable all scripts, since we don't have any anyway.
      scripts: false
      # By default do not allow any files to be served.
      allow: false
      # Whitelist common image file formats, plus HTML files, robots.txt
      # All other requests will be rejected.
      rules:
        \.(css|js|gif|jpe?g|png|ttf|eot|woff2?|otf|html|ico|svg?)$:
          allow: true
        ^/robots\.txt$:
          allow: true

4. Set Up Routes and Services

In the root directory create a .platform directory. Create a routes.yaml file.

# vuespa/.platform/routes.yaml

"https://backend.{default}/":
  type: upstream
  upstream: "go-backend:http"
"https://{default}/":
  type: upstream
  upstream: "vuejs-frontend:http"

Create an empty services.yaml file.

# vuespa/.platform/services.yaml

5. Deploy

Set the repository’s remote to an empty Platform.sh project using its project ID, which can be found within the Web UI or by listing the accounts active projects with the Platform.sh CLI, platform project:list.

$ platform project:set-remote <project ID>

Commit changes and deploy.

$ git init
$ git add .
$ git commit -m "Init commit"
$ git push platform master

6. Verify

Visit the frontend URL to see the following message:

Retrieved the following greetings message from API Go backend: Hello World!

Conclusion

Setting Up a Vue.js SPA + Golang API within a muti-app project on Platform.sh is pretty simple. It requires no local compilation and the project is instantly testable via an HTTPS URL.

An improvement would be to dynamically retrieve the backend URL in the frontend app (for API results fetching), and conversely to retrieve the frontend URL in the backend app (for CORS whitelisting).