In today's tutorial, we will learn how to self-host and set up our server, which will enable us to deploy any web application online. There are a few ways of deploying applications online. Two strategies involve using a VPS, a Virtual Private Server, and a shared managed hosting platform like Vercel, Netlify, WordPress, GoDaddy, etc.
A VPS is a virtual machine that provides dedicated server resources on a physically shared server with other users. It is actually a middle-tier hosting for websites and applications, offering more control and customization compared to shared hosting. Examples of some VPS hosting platforms include Hetzner, Akamai, Vultr, Cloudcone.
On the other hand, it is easier to host websites using a managed hosting platform. Such platforms provide tools, workflows, and infrastructure for building and deploying web applications. These platforms perform load balancing and caching on their own. They work well for any developer looking to build and deploy web applications quickly without further configurations.
As always, there are pros and cons to using each strategy. One of the most significant differences is that when using a VPS, you get complete customization since you are in control of the whole server and all that comes with it. That means setting up the development environment, firewall rules, hosting, etc. This customization adds to the complexity, and you need more technical support since you are doing everything yourself. You should know that a managed hosting platform is really friendly to beginners since most of the tools are pre-set, and you get support and documentation. Because it's already set up and managed, you will not get that high level of customization you would get from VPS.
Also, you have to take into consideration the price difference. Most VPS are paid, although you can fully customize your server to make it lightweight or as robust as you will ever need. In performance terms, that puts it a step above any managed hosting platform. The latter does have free plans, so you need to look at the differences. The general consumers would want managed hosting platforms because they are free. However, if you desire more power and want to host advanced applications within one application, then VPS is the way to go.
Our Watchlist Tracker application is a straightforward but powerful full-stack CRUD application that will be used to track movie watchlists. This application will also enable its users to easily add movies or series that they want to watch, update the movie title or rating, and remove movies they have already watched or no longer wish to track. The app gives users access to a simple interface to organize and catch up on films of interest, making it a tool fit for those movie enthusiasts who want to stay ahead in keeping tabs on their watchlist.
You can see what the app looks like below:
Homepage
Movie/series Item Page
Add New Item page
Before we begin building the application, it's essential to have your development environment set up and working. Ensure that you have the following installed on your machine:
There is an excellent free SQLite Viewer extension for VS Code, which can be helpful, too, alongside using the command line. It's good for quickly viewing the data inside of your database.
Our Watchlist Tracker app is built using a very modern and forward-thinking technical stack, which provides an excellent development experience. I have chosen to use tools like Bun, Hono, Vite, TanStack, Hetzner, and DeployHQ because they all offer developers a modern build experience.
Let's take a look at the technologies we will be using in this project:
Backend
Frontend
Hosting and deployment
Okay, let's start building our app! This section will be split into two parts: first, we will make the backend, and then we will create the frontend.
Please create a new folder on your computer for the project called watchlist-tracker-app and then cd into it. Now, create a new Bun project for the backend by using these commands shown here:
mkdir backend cd backend bun init -y mkdir src touch src/server.ts
Our project should now be set up. We just have to install dependencies, work on the configuration, and write some server code. Open the project in your code editor.
Now install the dependencies for our server using this command:
bun add hono prisma @prisma/client
We have added Bun as a runtime environment, Hono as our API server, and Prisma as our database ORM.
Lets now setup Prisma ORM and SQLite with this command:
npx prisma init
Prisma should be configured to work in our server now, so in the next step, we shall configure our database schema. So replace all of the code in prisma/schema.prisma with this code:
datasource db { provider = "sqlite" url = "file:./dev.db" } generator client { provider = "prisma-client-js" } model WatchlistItem { id Int @id @default(autoincrement()) name String image String rating Float description String releaseDate DateTime genre String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
Our database schema is set up, and it is connected to our SQLite database file.
Now run this migration script to create the SQLite database:
npx prisma migrate dev --name init
Great, now that the Prisma migration is complete, we can work on our API file.
Lets now create our main API file, which will use Hono. Go to the server file inside of src/server.ts and add this code to the file:
import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { PrismaClient } from '@prisma/client'; const app = new Hono(); app.use(cors()); const prisma = new PrismaClient(); app.get('/watchlist', async (c) => { const items = await prisma.watchlistItem.findMany(); return c.json(items); }); app.get('/watchlist/:id', async (c) => { const id = c.req.param('id'); const item = await prisma.watchlistItem.findUnique({ where: { id: Number(id) }, }); return item ? c.json(item) : c.json({ error: 'Item not found' }, 404); }); app.post('/watchlist', async (c) => { const data = await c.req.json(); const newItem = await prisma.watchlistItem.create({ data }); return c.json(newItem); }); app.put('/watchlist/:id', async (c) => { const id = c.req.param('id'); const data = await c.req.json(); const updatedItem = await prisma.watchlistItem.update({ where: { id: Number(id) }, data, }); return c.json(updatedItem); }); app.delete('/watchlist/:id', async (c) => { const id = c.req.param('id'); await prisma.watchlistItem.delete({ where: { id: Number(id) } }); return c.json({ success: true }); }); Bun.serve({ fetch: app.fetch, port: 8000, }); console.log('Server is running on http://localhost:8000'); export default app;
With this file, our server will be using Bun and Hono and run on port 8000. We also have all of the CRUD (Create, Read, Update, Delete) endpoints for our watchlist tracker. All our data is saved inside an SQLite database.
All that remains is to create the run and build scripts for our server. Then, we can test the endpoints to make sure that they work as expected. Add these run scripts to our package.json file:
"scripts": { "start": "bun run src/server.ts", "build": "bun build src/server.ts --outdir ./dist --target node" },
Ok, now if you run the command bun run start, you should see this in your terminal confirming that the server is running:
Server is running on http://localhost:8000
If you run the command bun run build, it should create a dist folder that is ready for production. We will need this when we deploy our application on Hetzner or any online server.
Alright, let's quickly test our backend endpoints to ensure that they work as expected. Then, we can start working on our front end. We have five endpoints to test: two GET, one POST, one PUT, and one DELETE. I'm going to use Postman to test the API.
Watchlist App API POST Endpoint
Method: POST
Endpoint: http://localhost:8000/watchlist
This is our POST endpoint, which is used to send a JSON object with the movie/series data to our database.
Watchlist App API GET All Endpoint
Method: GET
Endpoint: http://localhost:8000/watchlist
This is our primary GET endpoint, which will return an array of objects that our front end will fetch. It returns an array of objects with our data or an empty array if we have yet to post any data to our database.
Watchlist App API GET By ID Endpoint
Method: GET
Endpoint: http://localhost:8000/watchlist/3
This is our GET endpoint for getting items by their ID. It returns only that object and shows an error if the item does not exist.
Watchlist App API PUT Endpoint
Method: PUT
Endpoint: http://localhost:8000/watchlist/3
This is our PUT endpoint for updating items using their ID. It returns only that object and shows an error if the item does not exist.
Watchlist App API DELETE Endpoint
Method: DELETE
Endpoint: http://localhost:8000/watchlist/3
This is our DELETE endpoint for deleting items using their ID. It returns a success object and shows an error if the item does not exist.
That is, our API up and running. We can start on the front-end code now.
Make sure that you are inside the root folder for watchlist-tracker-app, and then run these scripts below to create a React project using Vite that is set up for TypeScript with all of our packages and dependencies:
mkdir backend cd backend bun init -y mkdir src touch src/server.ts
This script basically uses the Bun runtime environment to install and set up our project. All of the necessary files and folders have been created, so we just need to add the code. We have setup our Vite project to use Tailwind CSS for the styling and we have TanStack Router for page routing with axios for fetching data and dayjs for doing date conversions in our form.
Thanks to this build script, our job is now significantly simpler, so let's start adding the code to our files. Up first will be some configuration files. Replace all of the code inside the tailwind.config.js file with this code:
bun add hono prisma @prisma/client
This file is pretty explanatory. We need this file so that Tailwind CSS works throughout our project.
Now replace all of the code in our src/index.css file with this code, we need to add Tailwind directives so we can use them in our CSS files:
mkdir backend cd backend bun init -y mkdir src touch src/server.ts
With these directives added we can access Tailwind CSS styles in all of our CSS files. Next delete all of the CSS code inside of the App.css file as we no longer need it.
Alright, now for the final configuration file, and then we can work on our pages and components.
Add this code to the api.ts file in the root folder:
bun add hono prisma @prisma/client
This file exports the endpoints, which our frontend will need to connect to on our backend. Remember that our backend API is located at http://localhost:8000.
Ok good let's replace all of the code in our App.tsx file with this new code:
npx prisma init
This is our main entry point component for our app and this component holds the routes for all of our pages.
Next, we shall work on the main components and pages. We have three component files and three pages of files. Starting with the components add this code to our file in components/AddItemForm.tsx:
datasource db { provider = "sqlite" url = "file:./dev.db" } generator client { provider = "prisma-client-js" } model WatchlistItem { id Int @id @default(autoincrement()) name String image String rating Float description String releaseDate DateTime genre String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
This component is used for adding items to our database. The user will use this form component to send a POST request to the backend.
Now lets add the code for components/FormField.tsx:
npx prisma migrate dev --name init
This is a reusable form field component for our form. It keeps our code DRY because we can just use the same component for multiple fields which means our codebase is smaller.
And lastly lets add the code for components/Header.tsx:
import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { PrismaClient } from '@prisma/client'; const app = new Hono(); app.use(cors()); const prisma = new PrismaClient(); app.get('/watchlist', async (c) => { const items = await prisma.watchlistItem.findMany(); return c.json(items); }); app.get('/watchlist/:id', async (c) => { const id = c.req.param('id'); const item = await prisma.watchlistItem.findUnique({ where: { id: Number(id) }, }); return item ? c.json(item) : c.json({ error: 'Item not found' }, 404); }); app.post('/watchlist', async (c) => { const data = await c.req.json(); const newItem = await prisma.watchlistItem.create({ data }); return c.json(newItem); }); app.put('/watchlist/:id', async (c) => { const id = c.req.param('id'); const data = await c.req.json(); const updatedItem = await prisma.watchlistItem.update({ where: { id: Number(id) }, data, }); return c.json(updatedItem); }); app.delete('/watchlist/:id', async (c) => { const id = c.req.param('id'); await prisma.watchlistItem.delete({ where: { id: Number(id) } }); return c.json({ success: true }); }); Bun.serve({ fetch: app.fetch, port: 8000, }); console.log('Server is running on http://localhost:8000'); export default app;
Because of this header component, each page has a header with a main navigation.
All we have left is the three pages and then our app is done. So add this following code to pages/AddItem.tsx:
"scripts": { "start": "bun run src/server.ts", "build": "bun build src/server.ts --outdir ./dist --target node" },
This is essentially the page for adding items to our database which has the form component in it.
Right so next lets add the code for pages/Home.tsx:
Server is running on http://localhost:8000
As you can imagine, this will be our homepage, which sends a GET request to the backend, which then retrieves an array of objects for all of the items in our database.
Finally add the code for pages/ItemDetail.tsx:
bun create vite client --template react-ts cd client bunx tailwindcss init -p bun install -D tailwindcss postcss autoprefixer tailwindcss -p bun install @tanstack/react-router axios dayjs cd src mkdir components pages touch api.ts touch components/{AddItemForm,FormField,Header}.tsx pages/{AddItem,Home,ItemDetail}.tsx cd ..
This page displays individual item pages by their ID. There is also a form for editing and deleting items from the database.
That's it. Our application is ready to use. Make sure that both the backend and client servers are running. You should see the app running in the browser here: http://localhost:5173/.
Run both servers with these commands inside their folders:
module.exports = { content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], theme: { extend: {}, }, plugins: [], };
Ok, well done. Our application is complete! Let's now deploy it to GitHub!
Deploying our application to GitHub is pretty straightforward. Firstly put a .gitignore file inside of the root folder for our watchlist-tracker-app. You can just copy and paste the .gitignore file from either the backend or client folders. Now go to your GitHub (create an account if you do not have one) and create a repo for your watchlist-tracker-app.
Inside of the root folder for watchlist-tracker-app use the command line to upload your codebase. See this example code and adapt it for your own repository:
mkdir backend cd backend bun init -y mkdir src touch src/server.ts
There is one very important last point to be aware of. When we upload our code to Hetzner, we will no longer be able to access the backend via localhost, so we have to update the API route. There is a variable called const API_URL = 'http://localhost:8000'; in two files at the top of them. The files are api.ts and components/AddItemForm.tsx. Replace the variable API URL with the one below and then re-upload your codebase to GitHub.
bun add hono prisma @prisma/client
That's all there is to it. Your codebase should now be online on GitHub so we can now work on deploying it to Hetzner.
With our watchlist tracker app complete, it's now time to deploy our application to the Hetzner VPS platform. Hetzner is a paid platform, but the versatility it offers is unmatched. The cheapest plan is around €4.51/$4.88/£3.76 a month. It's well worth having your self-hosted online server because, as a developer, you can use it for learning, practice, production deployments, and so much more. You can always cancel the server subscription and get it back when needed.
Most VPS are essentially the same because they are all servers that can run different operating systems, like Linux. Each VPS provider has a different interface and setup, but the fundamental structure is quite the same. By learning how to self-host an application on Hetzner, you can easily reuse these same skills and knowledge to deploy an application on a different VPS platform.
Some use cases for a VPS include:
You can see what the current pricing looks like for Hetzner here:
Go to the Hetzner website and click on the red Sign Up button in the middle of the page. Alternatively, you can click on the Login button in the top right-hand corner. You should see a menu with options for Cloud, Robot, konsoleH, and DNS. Click on any one of them to go to the login and register form page.
Now, you should see the login and register form page. Click the register button to create an account and complete the sign-up process. You will probably need to pass the verification stage by either using your PayPal account or having your passport ready.
You should now be able to log in to your account and create and buy a server. Choose a location that is near you, and then select Ubuntu as the image. See this example for reference.
Under Type, choose shared vCPU, and then select a configuration for your server. We are deploying a simple application that only requires a few resources. If you want, feel free to get a better server—it's up to you. A dedicated vCPU performs better but costs more money. You can also choose between x86 (Intel/AMD) and Arm64 (Ampere) processors.
The default configuration should be acceptable. See the example below. For security reasons, though, it's important to add an SSH key.
SSH keys provide a more secure way to authenticate to your server than a traditional password. The key needs to be in OpenSSH format, ensuring a high level of security for your server. Depending on your operating system, you can do a Google search for "Generate an SSH key on Mac" or "Generate an SSH key on Windows." I will give you a quick guide for generating an SSH key on a Mac.
Firstly open your terminal application and then type the following command replacing "your_email@example.com" with your actual email address:
mkdir backend cd backend bun init -y mkdir src touch src/server.ts
The -b 4096 part ensures that the key is 4096 bits for increased security. Next save the key after prompted to do so. You can accept the default location or choose a custom one:
bun add hono prisma @prisma/client
Setting a passphrase is optional you can skip this step. Now, load the SSH key into your SSH agent by running the following commands:
First, start the agent:
npx prisma init
Then, add the SSH key:
datasource db { provider = "sqlite" url = "file:./dev.db" } generator client { provider = "prisma-client-js" } model WatchlistItem { id Int @id @default(autoincrement()) name String image String rating Float description String releaseDate DateTime genre String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
To copy your SSH public key to your clipboard, run:
npx prisma migrate dev --name init
This copies the public key so you can add it to Hetzner or any other service that requires an SSH key. Paste your SSH key into this form box and add it.
You don't need to worry about Volumes, Placement groups, Labels, or Cloud config because they are outside the scope of this project. Backups can be helpful, but they will add cost, so they are optional for this project. We will do the Firewall later, so you don't have to worry about it now. Choose a name for your server, and then go ahead and create and buy the server with your current settings, and your Hetzner account will be ready to go.
Ok, good. We now have an account on Hetzner. In the next section, we will set up our firewall, configure our Linux operating system, and get our application online.
Before we SSH into our Linux OS, let's first set up our firewall rules. We need port 22 open so that we can use SSH, and we need port 80 open as port 80 is a TCP port that's the default network port for web servers using HTTP (Hypertext Transfer Protocol). It's used to communicate between web browsers and servers, delivering and receiving web content. This is how we get our application to work online.
Navigate to Firewalls in the main menu and then create a firewall with the inbound rules shown below:
Now, with our firewall working, we can set up a Linux environment and get our application deployed, so let's do that next.
Connecting to our remote Hetzner server should be pretty straightforward because we have created an SSH key for logging in. We can connect using the terminal or even a code editor like VS Code, which will give us the ability to see and browse our files much more easily without having to use the command line. If you want to use the Visual Studio Code Remote - SSH extension, you can do so. For this project, we will stick with the terminal.
Open your terminal and type this SSH command to log in to your remote server. Replace the address 11.11.111.111 with your actual Hetzner IP address, which you can find in the servers section:
mkdir backend cd backend bun init -y mkdir src touch src/server.ts
You should now be logged into your server, which displays the welcome screen and other private information like your IP address. I'm just showing the welcome part of the screen here:
bun add hono prisma @prisma/client
Okay, great. Now, we can finally start to run some commands that will set up our development environment before we get our application online. By the way, if you want to exit and close the SSH connection to your server, you can either type exit followed by clicking the enter button or use the keyboard shortcut Ctrl D.
The first thing we need to do is update and upgrade the packages on our Linux system. We can do that with one command, so type this command into the terminal and hit enter:
npx prisma init
Now, we need to install the rest of our development packages and dependencies. First up is Node.js and npm, so install them with this command:
datasource db { provider = "sqlite" url = "file:./dev.db" } generator client { provider = "prisma-client-js" } model WatchlistItem { id Int @id @default(autoincrement()) name String image String rating Float description String releaseDate DateTime genre String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
Next is Nginx, which we will need to use as a reverse proxy. A reverse proxy is a server that sits between clients and backend servers, forwarding client requests to the appropriate backend server and then returning the server's response to the client. It acts as an intermediary, managing and routing incoming requests to improve performance, security, and scalability. So install it with this command:
npx prisma migrate dev --name init
We will require Git, for which we need to pull our code from GitHub and upload it to our remote server. So install it with this command:
import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { PrismaClient } from '@prisma/client'; const app = new Hono(); app.use(cors()); const prisma = new PrismaClient(); app.get('/watchlist', async (c) => { const items = await prisma.watchlistItem.findMany(); return c.json(items); }); app.get('/watchlist/:id', async (c) => { const id = c.req.param('id'); const item = await prisma.watchlistItem.findUnique({ where: { id: Number(id) }, }); return item ? c.json(item) : c.json({ error: 'Item not found' }, 404); }); app.post('/watchlist', async (c) => { const data = await c.req.json(); const newItem = await prisma.watchlistItem.create({ data }); return c.json(newItem); }); app.put('/watchlist/:id', async (c) => { const id = c.req.param('id'); const data = await c.req.json(); const updatedItem = await prisma.watchlistItem.update({ where: { id: Number(id) }, data, }); return c.json(updatedItem); }); app.delete('/watchlist/:id', async (c) => { const id = c.req.param('id'); await prisma.watchlistItem.delete({ where: { id: Number(id) } }); return c.json({ success: true }); }); Bun.serve({ fetch: app.fetch, port: 8000, }); console.log('Server is running on http://localhost:8000'); export default app;
The Bun runtime will be helpful for running our applications. First, we have to install the unzip package as required prior to installing Bun. Afterwards, we can install Bun. These are the commands we need:
mkdir backend cd backend bun init -y mkdir src touch src/server.ts
bun add hono prisma @prisma/client
To run both the React frontend and the backend server on the same Hetzner server, we need to manage both services simultaneously. This typically involves setting up a process manager like PM2 to run the backend server and using Nginx as a reverse proxy to handle incoming requests to both the front end and back end.
Install PM2 by using this command here:
npx prisma init
Right, that takes care of our Linux environment setup. In the next section, we will download our codebase from GitHub onto our remote server and configure our Nginx server to get our app working online.
I'm going to assume that you already know how to navigate the command line. If not you can Google it. We will be changing directories and managing files. Start by cloning the GitHub repo with your project and copying it onto the remote server. Use the command below for reference:
datasource db { provider = "sqlite" url = "file:./dev.db" } generator client { provider = "prisma-client-js" } model WatchlistItem { id Int @id @default(autoincrement()) name String image String rating Float description String releaseDate DateTime genre String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
Our web application files will be stored inside of /var/www. You can see all the files on your Linux OS by using the commands ls which is used to list files and pwd which is used to print the working directory. To learn more about the Linux command line take a look at this tutorial for The Linux command line for beginners.
So now that we have our application on our remote server we can create a production build of the backend and frontend. To do this, we just have to cd into the root for the folder backend and client inside of our watchlist-tracker-app project and run the commands shown below:
npx prisma migrate dev --name init
We are using Bun as our runtime so we will use the bun commands for the install and building steps.
Alright, now let's configure our Nginx server. We will use the nano terminal editor to write the code inside of the file. Run this command in the terminal to open a Nginx file for our watchlist tracker app:
import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { PrismaClient } from '@prisma/client'; const app = new Hono(); app.use(cors()); const prisma = new PrismaClient(); app.get('/watchlist', async (c) => { const items = await prisma.watchlistItem.findMany(); return c.json(items); }); app.get('/watchlist/:id', async (c) => { const id = c.req.param('id'); const item = await prisma.watchlistItem.findUnique({ where: { id: Number(id) }, }); return item ? c.json(item) : c.json({ error: 'Item not found' }, 404); }); app.post('/watchlist', async (c) => { const data = await c.req.json(); const newItem = await prisma.watchlistItem.create({ data }); return c.json(newItem); }); app.put('/watchlist/:id', async (c) => { const id = c.req.param('id'); const data = await c.req.json(); const updatedItem = await prisma.watchlistItem.update({ where: { id: Number(id) }, data, }); return c.json(updatedItem); }); app.delete('/watchlist/:id', async (c) => { const id = c.req.param('id'); await prisma.watchlistItem.delete({ where: { id: Number(id) } }); return c.json({ success: true }); }); Bun.serve({ fetch: app.fetch, port: 8000, }); console.log('Server is running on http://localhost:8000'); export default app;
If you're not familiar with the nano code editor, check out this cheat sheet.
You just need to copy and paste this configuration into the file and save it. Be sure to replace the server_name IP address with your own Hetzner IP address:
"scripts": { "start": "bun run src/server.ts", "build": "bun build src/server.ts --outdir ./dist --target node" },
Nginx is very often used to serve the role of a reverse proxy server. A reverse proxy sits between the client-your user's browser and your backend server. It receives requests from the client and forwards them to one or more backend servers. Once the backend processes the request, the reverse proxy forwards the response back to the client. In such a setup, Nginx would be the entry point for incoming traffic and route requests to specific services, say Vite frontend or API backend.
Vite production preview builds run on port 4173, and our backend server runs on port 8000. If you change these values, then make sure you update them in this Nginx configuration file, too; otherwise, the servers won't work.
Enable the Nginx site if it's not already enabled with this command:
mkdir backend cd backend bun init -y mkdir src touch src/server.ts
Now test the Nginx configuration to ensure there are no syntax errors and restart Nginx to apply the changes with these commands:
bun add hono prisma @prisma/client
We are almost done. Just one step left. If you go to your Hetzner IP address now in a browser, you should see an error like 502 Bad Gateway. That's because we have no running server yet; first, we need to run both servers simultaneously using PM2. So, we have to set PM2 to start on the system boot so that our application will always be online. Do this by running these commands in the terminal:
npx prisma init
Now, we have to get the backend and frontend servers running. Run these commands from inside the root of their folders.
Let's start with the backend server, so run this command in the terminal:
datasource db { provider = "sqlite" url = "file:./dev.db" } generator client { provider = "prisma-client-js" } model WatchlistItem { id Int @id @default(autoincrement()) name String image String rating Float description String releaseDate DateTime genre String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
Lastly, let's get the client frontend server running, so run this command in the terminal:
npx prisma migrate dev --name init
You can run the command pm2 status to check if both servers are online and running, as shown below. To learn about all of the other PM2 commands, read the documentation on PM2 Process Management Quick Start:
You can test if the servers are reachable by running these curl commands in the terminal. You should get back the HTML code if they are working:
import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { PrismaClient } from '@prisma/client'; const app = new Hono(); app.use(cors()); const prisma = new PrismaClient(); app.get('/watchlist', async (c) => { const items = await prisma.watchlistItem.findMany(); return c.json(items); }); app.get('/watchlist/:id', async (c) => { const id = c.req.param('id'); const item = await prisma.watchlistItem.findUnique({ where: { id: Number(id) }, }); return item ? c.json(item) : c.json({ error: 'Item not found' }, 404); }); app.post('/watchlist', async (c) => { const data = await c.req.json(); const newItem = await prisma.watchlistItem.create({ data }); return c.json(newItem); }); app.put('/watchlist/:id', async (c) => { const id = c.req.param('id'); const data = await c.req.json(); const updatedItem = await prisma.watchlistItem.update({ where: { id: Number(id) }, data, }); return c.json(updatedItem); }); app.delete('/watchlist/:id', async (c) => { const id = c.req.param('id'); await prisma.watchlistItem.delete({ where: { id: Number(id) } }); return c.json({ success: true }); }); Bun.serve({ fetch: app.fetch, port: 8000, }); console.log('Server is running on http://localhost:8000'); export default app;
Go to the IP address for your Hetzner server. If you did everything correctly, you should see your app deployed and online! Websites typically have domain names, and the IP address remains hidden from the search bar. It's fairly common for developers to buy domains and then change the nameservers to connect them to a host's server. This is beyond the scope of this tutorial, but you can easily learn how to do it by doing a Google search. Namecheap is my preferred domain register.
Right, we are very close to completion. Now, the final step is to use DeployHQ to streamline the deployment process. DeployHQ makes deployments easy and is much better for security purposes. The traditional way to update a codebase on an online server is to use git pull to get the latest changes from your GitHub repo. However, doing git pull is not a good practice since it might expose git folders, and the website most likely will not be minified, uglified, and so on.
DeployHQ plays a crucial role here. It securely copies the modified files in the configured folder, ensuring that no changes are visible in the git logs on your server. This may seem like a trade-off, but it's a security feature that reassures you of the safety of your deployment. If you're familiar with platforms like Vercel or Netlify, you'll find these auto deployments quite similar. In this case, you have a setup that can work with any online server on a VPS.
One thing worth mentioning is that it's important that we create a non-root user for our online Linux remote server. Signing in as the root user is not always the best practice; it's better to have another user set up with similar privileges. DeployHQ also discourages using the root user for sign-in. In order for DeployHQ to work, we need to use SSH to sign into our account. We will do this after we have a DeployHQ account because we have to place our DeployHQ SSH public key on our online Ubuntu server.
DeployHQ gives you free access to all of their features for 10 days with no obligations when you sign up for the first time. Afterward, your account will revert to a free plan that allows you to deploy a single project up to 5 times a day.
Start by going to the DeployHQ website and creating an account by clicking one of those buttons you see below:
Ok, you should now see the welcome to DeployHQ screen with a Create a project button. Click the button to create a project like shown here:
On the next screen, you need to create a new project. So please give it a name, and select your GitHub repo. Also, choose a zone for your project and then create a project:
You should now see the server screen, as shown below. This means it's time to create another Linux user for our remote server so we don't have to rely on the root user.
Start by logging into your server as the root user, and then create a new user with this command below. Replace new_username with a username that you want to use for the new user:
mkdir backend cd backend bun init -y mkdir src touch src/server.ts
You will be asked to set a password, and there will be prompts for entering details like your full name, room number, etc. You only need to set a password. You can skip the other prompt steps and leave them blank by pressing Enter until they are all gone.
It's also a good idea to add the new user to the sudo group so they can have administrative privileges like the root user. Do this with the command shown here:
mkdir backend cd backend bun init -y mkdir src touch src/server.ts
The new user now needs SSH access for the server. First switch to the new user and then create the .ssh directory for them with these commands:
bun add hono prisma @prisma/client
Now we have to add our local Public Key to authorized_keys on the server so on your local machine copy your public key with this command:
npx prisma init
Now on the server open the authorized_keys file so it can be edited with the nano editor:
datasource db { provider = "sqlite" url = "file:./dev.db" } generator client { provider = "prisma-client-js" } model WatchlistItem { id Int @id @default(autoincrement()) name String image String rating Float description String releaseDate DateTime genre String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
Paste the copied public key into this file. Before you save the file copy and paste the DeployHQ SSH key from the server page into the same file. You can see the SSH key when you check the box for Use SSH key rather than password for authentication?. Now save the file and set the correct permissions for the file with this command:
npx prisma migrate dev --name init
You can now test the SSH login for the new user. First, log out from the remote server and then try to log in again, but this time using the new user you created and not root. See the example below:
import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { PrismaClient } from '@prisma/client'; const app = new Hono(); app.use(cors()); const prisma = new PrismaClient(); app.get('/watchlist', async (c) => { const items = await prisma.watchlistItem.findMany(); return c.json(items); }); app.get('/watchlist/:id', async (c) => { const id = c.req.param('id'); const item = await prisma.watchlistItem.findUnique({ where: { id: Number(id) }, }); return item ? c.json(item) : c.json({ error: 'Item not found' }, 404); }); app.post('/watchlist', async (c) => { const data = await c.req.json(); const newItem = await prisma.watchlistItem.create({ data }); return c.json(newItem); }); app.put('/watchlist/:id', async (c) => { const id = c.req.param('id'); const data = await c.req.json(); const updatedItem = await prisma.watchlistItem.update({ where: { id: Number(id) }, data, }); return c.json(updatedItem); }); app.delete('/watchlist/:id', async (c) => { const id = c.req.param('id'); await prisma.watchlistItem.delete({ where: { id: Number(id) } }); return c.json({ success: true }); }); Bun.serve({ fetch: app.fetch, port: 8000, }); console.log('Server is running on http://localhost:8000'); export default app;
Assuming you did everything correctly, you should now be able to log in with your new user.
Now we can finally complete the server form. Fill in the form with your information see this example below:
Name: watchlist-tracker-app
Protocol: SSH/SFTP
Hostname: 11.11.111.111
Port: 22
Username: new_username
Use SSH key rather than password for authentication?: checked
Deployment Path: /var/www/watchlist-tracker-app
The deployment path should be the location on your server where your GitHub repo is. Now you can go ahead and create a server. If you encounter any problems then it might be due to your firewall settings so read the documentation on Which IP addresses should I allow through my firewall?.
You should now see the New Deployment screen as shown here:
After a successful deployment, you should be presented with this screen:
The final step is to set up automatic deployments so that when you push changes from your local repository to GitHub, they are automatically deployed to your remote server. You can do this from the Automatic Deployments page, which is located on the left sidebar of your DeployHQ account. See the example here:
We are all done; congratulations, you have learned how to build a full-stack React application, deploy the codebase to GitHub, host the application on a VPS running Linux Ubuntu, and set up automatic deployments with DeployHQ. Your developer game has leveled up!
It's efficient and enjoyable to build a full-stack CRUD app like Watchlist Tracker with modern tools and technologies. We used a pretty strong tech stack: Bun, Hono, Prisma ORM, and SQLite on the back end, while Vite, Tailwind CSS, and TanStack Router on the front end help make it responsive and functional. Hetzner will ensure that the app is reliable and well-performing no matter where the users are.
Deployment by DeployHQ makes deployment quite easy. You just need to push updates straight from your Git repository to the cloud server. Any changes you make in your repository will automatically be deployed to your production server so that the latest version of your application is live. This saves time because automated deployments cut down on the number of errors related to deployment, so it is worth adding to any form of development workflow.
This tutorial should help you deploy all kinds of applications to production using a VPS like Hetzner with automatic git deployments thanks to DeployHQ.
The above is the detailed content of Deploying a React Watchlist Tracker App to Production Using DeployHQ. For more information, please follow other related articles on the PHP Chinese website!