Skip to content

Instantly share code, notes, and snippets.

@infomiho
Last active January 21, 2025 19:53
Show Gist options
  • Save infomiho/80f3f50346566e39db56c5e57fefa1fe to your computer and use it in GitHub Desktop.
Save infomiho/80f3f50346566e39db56c5e57fefa1fe to your computer and use it in GitHub Desktop.
Deploy Wasp to a VPS (reverse proxy + Docker)

Deploying a Wasp app to a VPS

Let's deploy a Wasp app directly to a VPS. We'll do things directly on the machine without any management dashboard.

We'll need a couple of things to get this going:

  • a VPS (I'll be using Hetzner, but it doesn't matter which provider you use)
  • a domain name (mostly to have HTTPS)

Our setup will look like this:

  • Ubuntu LTS
  • deploy by cloning the repo on the VPS and pulling manually when we want to deploy
  • a reverse proxy (Caddy) - it's a thing that enables us to use a domain and HTTPS
  • serving the client as static files
  • running the backend using Docker and exposing it on a subdomain
  • running the DB using Docker

We'll do some basic security - allow only ports 80 and 443 to be exposed to the internet and close everything else.

0. Connect to your server via SSH

It depends on your provider how you do it - sometimes they give you a password, and sometimes you upload your public SSH key.

But you'll connect like this:

ssh <username>@<server-ip>

Usually the username is root if the provider doesn't tell you otherwise.

1. Install our reverse proxy (Caddy)

You may need to run apt update before you start.

Sometimes you need to uninstall Apache 2 if you are using Ubuntu. If which apache2 doesn't give a path - you don't have Apache 2 installed - carry on.

Follow the install instructions for Ubuntu: https://caddyserver.com/docs/install#debian-ubuntu-raspbian

After you install Caddy, visit your server's IP and you'll see the "Your web server is working. Now make it work for you. 💪" message.

2. Setup the firewall (ufw)

We want only to allow SSH connections and connections on ports 80 and 443.

Here are the commands to run:

ufw default deny incoming
ufw default allow outgoing

# Allow SSH connections
ufw allow ssh
ufw show added

# Make sure to run this AFTER you allow SSH connections
ufw enable
ufw allow http
ufw allow https

Based on: https://www.digitalocean.com/community/tutorials/how-to-set-up-a-firewall-with-ufw-on-ubuntu

3. Install Docker

We'll install Docker by following: https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository

If docker run hello-world works, you have installed Docker successfully!

4. Setup a deployment SSH key for your Github repo

To clone and pull from private Github repo, you need to add your server's public SSH key as a Github deploy key.

Generate a new key on the server:

ssh-keygen

I select the default options.

Now, get the public key value by running cat /<username>/.ssh/<key-name>.pub.

Go to https://github.com/<username>/<repo-name>/settings/keys/new to add it.

5. Clone your repository

You can now do git clone [email protected]:<username>/<repo-name>.git to get your repo on your VPS.

When you want to redeploy, you'll be able to run git pull to get the latest file changes to your VPS.

6. Install Wasp on the server

We'll need the Wasp CLI to be able to build the app.

Install the latest version with:

curl -sSL https://get.wasp-lang.dev/installer.sh | sh

Make sure your Wasp CLI binary is available by adding the export PATH=$PATH:/<username>/.local/bin line to your ~/.bashrc file. Then load the new .bashrc by running source ~/.bashrc. You might need to disconnect and connect again to the server.

Verify Wasp is installed by running wasp version. You'll see a Node.js-related error - this is expected - we'll install it next.

7. Install Node.js

We'll install Node.js with nvm by following: https://nodejs.org/en/download/package-manager

You might need to disconnect and connect to the server for nvm to start working. But then you'll be able to install Node.js 20 with nvm install 20.

8. Build the app for production

Okay, now that we have most of the dependencies installed, let's build our server:

  1. Build the app for production with wasp build in the project dir.

  2. Go into the .wasp/build dir.

  3. Build the server with the following command

    docker build . -t wasp-vps-app

    Replace the app name (wasp-vps-app) with something else if you want.

We'll run the server container in a bit, we first need to set up a few things.

9. Start the DB with Docker

First, we'll create a Docker network so our server and the DB can communicate:

docker network create wasp-vps-network

Then we'll start our PostgreSQL 16 with:

docker run -d \
  --name db \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -v postgres_data:/var/lib/postgresql/data \
  -p 127.0.0.1:5432:5432 \
  --network wasp-vps-network \
  postgres:16

Note

We are allowing connections to the DB only from the server with 127.0.0.1:5432:5432 to keep things secure. We do this because Docker port bindings bypass our firewall.

Tip

On the server, you can connect to the DB with:

docker exec -it db psql -U postgres

10. Get a domain name

We'll need a domain name to keep things secure and looking nice.

In the examples, I'll use https://myapp.com for my client and https://api.myapp.com for my server.

Set up the DNS records for your domain like so:

  • A record with value of @ (root) should go to your server IPv4 address
  • A record with value of api should go to your server IPv4 address

If you only have a IPv6 address, I believe you can use the AAAA record instead of an A record and things should work - but I haven't tested this myself.

11. Start your server app

Go to the project dir (not .wasp/build) and create a .env.production file there with the following:

PORT=3001
DATABASE_URL=postgresql://postgres:mysecretpassword@db:5432/postgres
WASP_WEB_CLIENT_URL=https://myapp.com
WASP_SERVER_URL=https://api.myapp.com
# Generate the JWT secret with https://djecrety.ir/
JWT_SECRET=<some-random-string>

# Add any other env vars you need for your server e.g. GOOGLE_CLIENT_ID and similar.

Then run your server app with:

docker run -d \
  --name wasp-vps-app \
  --env-file .env.production \
  -p 127.0.0.1:3001:3001 \
  --network wasp-vps-network \
  wasp-vps-app

Verify your server is running by running curl localhost:3001, you should get "Hello world" as output.

Tip

You can check the container logs with:

docker container logs wasp-vps-app -f

12. Build and set up the client

Build your client app by going into .wasp/build/web-app and running:

npm install
REACT_APP_API_URL=https://api.myapp.com npm run build

Make a new folder somewhere where you'll copy the client files e.g. ~/client.

Copy the contents of the client build folder there:

cp -R ~/<project-name>/.wasp/build/web-app/build/* ~/client`

13. Setup Caddy to serve our server and client

Edit the Caddyfile at /etc/caddy/Caddyfile to have:

myapp.com {
        root * /<username>/client
        encode gzip
        try_files {path} /index.html
        file_server
}

api.myapp.com {
        reverse_proxy localhost:3001
}

We've set up the client app as a SPA app - all the unknown routes go to index.html.

Notice the /<username>/client folder - that should be the same folder that you created in the previous step. For me, it was /root/client.

The server is set up to forward all the requests to localhost:3001 where our server Docker container is running.

Reload Caddy with:

caddy reload --config /etc/caddy/Caddyfile

You should now see your client at https://myapp.com and your API at https://api.myapp.com, yay!

Redeploying on change

To deploy the latest changes:

  1. git pull on the server
  2. Rerun wasp build in the project dir
  3. Stop and delete the server container
    • docker container stop wasp-vps-app && docker container rm wasp-vps-app (there will be some downtime until you start the new container)
  4. Rebuild the server Docker container
  5. Restart the server container
  6. Rebuild the client with npm build

Ideally, you'd have a script that runs all the steps:

redeploy.sh
#!/bin/bash

# Define colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# Function to print messages with color
print_msg() {
    echo -e "${BLUE}$1${NC}"
}

# Replace <app-name> with your actual application name
APP_NAME="<app-name>"

print_msg "Navigating to the project directory..."
cd ~/"$APP_NAME" || { echo -e "${RED}Failed to change directory to ~/${APP_NAME}!${NC}"; exit 1; }

print_msg "Pulling latest changes from git..."
git pull || { echo -e "${RED}Git pull failed!${NC}"; exit 1; }

print_msg "Building the Wasp project..."
wasp build || { echo -e "${RED}Wasp build failed!${NC}"; exit 1; }

print_msg "Stopping and removing the existing Docker container..."
docker container stop wasp-vps-app && docker container rm wasp-vps-app || { echo -e "${YELLOW}Failed to stop or remove Docker container. It might not exist.${NC}"; }

print_msg "Navigating to the build directory..."
cd .wasp/build/ || { echo -e "${RED}Failed to change directory to .wasp/build!${NC}"; exit 1; }

print_msg "Building the Docker image..."
docker build . -t wasp-vps-app || { echo -e "${RED}Docker build failed!${NC}"; exit 1; }

print_msg "Returning to the project root directory..."
cd - || { echo -e "${RED}Failed to change directory!${NC}"; exit 1; }

print_msg "Running the Docker container..."
docker run -d --name wasp-vps-app --env-file .env.production -p 127.0.0.1:3001:3001 --network wasp-vps-network wasp-vps-app || { echo -e "${RED}Failed to run Docker container!${NC}"; exit 1; }

print_msg "Navigating to the web-app build directory..."
cd .wasp/build/web-app/ || { echo -e "${RED}Failed to change directory to .wasp/build/web-app!${NC}"; exit 1; }

print_msg "Installing npm dependencies..."
npm install || { echo -e "${RED}npm install failed!${NC}"; exit 1; }

print_msg "Building the React app..."
REACT_APP_API_URL=https://api.myapp.com npm run build || { echo -e "${RED}React build failed!${NC}"; exit 1; }

print_msg "Copying built files to the client directory..."
cp -R ~/"$APP_NAME"/.wasp/build/web-app/build/* ~/client/ || { echo -e "${RED}Failed to copy files!${NC}"; exit 1; }

echo -e "${GREEN}Deployment completed successfully!${NC}"

Make sure to make the script executable with chmod +x redeploy.sh so you can run it with ./redeploy.sh.

Bonus: minimize downtime

Check the options that Caddy offers to minimize downtime: https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#load-balancing

You can set it so that Caddy waits for a few seconds and retries a couple of times before it tells the client that the server is not up. For example, 15s delay could be enough to ensure that clients don't see that our server restarted:

myapp.com {
	root * /<username>/client
	encode gzip
	try_files {path} /index.html
	file_server
}

api.myapp.com {
	reverse_proxy localhost:3001 {
		health_uri /
		lb_try_duration 15s
	}
}

These directives make Caddy wait for up to 15s until the server is back up:

health_uri /
lb_try_duration 15s

Bonus: setup Cloudflare CDN

You can move your domain's nameservers to Cloudflare to get the benefits of their CDN and DDoS protections.

A couple of things to be keep in mind:

  • Add the A records in DNS Only / gray mode first, so Caddy manages to get you a HTTPS certificate
  • After you have everything working with the DNS Only mode, you can switch the A records to the Proxy / orange mode
    • You'll need to use the Full (Strict) SSL option for this to work

There might be a better way to set up SSL with Caddy + Cloudflare, but this usually does the trick for me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment