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.
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.
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.
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
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!
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.
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.
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.
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
.
Okay, now that we have most of the dependencies installed, let's build our server:
-
Build the app for production with
wasp build
in the project dir. -
Go into the
.wasp/build
dir. -
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.
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
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.
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
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`
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!
To deploy the latest changes:
git pull
on the server- Rerun
wasp build
in the project dir - 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)
- Rebuild the server Docker container
- Restart the server container
- 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
.
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
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 inDNS 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 theA
records to theProxy / orange
mode- You'll need to use the
Full (Strict)
SSL option for this to work
- You'll need to use the
There might be a better way to set up SSL with Caddy + Cloudflare, but this usually does the trick for me.