Building bloodstrike backend · Lesson 2 of 4
Deploying a Node.js Game Server on Ubuntu
From a GitHub repository to a persistent systemd service
Once the VPS was prepared, the next step was to deploy the Bloodstrike game server. We wanted the application to run reliably without keeping an SSH terminal open. We also wanted it to restart automatically if the process failed.
Getting the code from GitHub
Our source code was stored in a private GitHub repository. The VPS needed a secure way to download it without using a personal account password.
We created a dedicated SSH deploy key on the server and added its public key to the repository as a read-only deploy key.
We then tested the connection to GitHub.
ssh -T git@github.com
After confirming access, we cloned the project.
mkdir -p ~/apps
cd ~/apps
git clone YOUR_PRIVATE_REPOSITORY_SSH_URL cs-bloodstrike
cd cs-bloodstrike
Use the SSH URL of your own repository instead of the placeholder.
A deploy key is useful because it gives the server access to a specific repository without granting access to an entire personal GitHub account.
Installing Node.js and project dependencies
The game server is a Node.js application. We installed a supported Node.js release and checked the versions.
node -v
npm -v
Our environment used Node.js 24 and npm 11.
We installed the dependencies recorded in the lockfile.
cd ~/apps/cs-bloodstrike
npm ci
The npm ci command installs the locked dependencies. It is well suited to repeatable deployments because it follows the lockfile rather than freely changing dependency versions.
Building and testing the game server
Installing dependencies does not automatically mean that the application is ready to run.
We built the server and ran the project’s tests.
npm run build:server
npm run test:server
Both commands completed successfully in our setup.
The build generated the production server bundle at:
apps/game-server/dist/server.mjs
The project uses ws for WebSocket support. The package was present in the game-server workspace.
npm ls ws
This is an important distinction: the game server itself handles WebSocket connections. We do not need to install a second WebSocket server just to enable secure connections.
Testing the application locally
Before making the application available through a public domain, we tested its health endpoint on the VPS.
The server supports a configurable host and port. We used a local-only binding for this quick test.
HOST=127.0.0.1 PORT=8080 npm run start:server
In another terminal session, we ran:
curl -i http://127.0.0.1:8080/health
The response contained:
HTTP/1.1 200 OK
ok
This confirmed that the application started and responded to HTTP requests.
After the test, we stopped the manually started process. The production process would be managed by systemd instead.
Keeping configuration outside the source code
Applications often need configuration values such as a listening port, allowed browser origins, and connection limits.
We stored these settings in a dedicated environment file:
/etc/bloodstrike-game.env
A simplified example looks like this:
HOST=127.0.0.1
PORT=8080
ALLOWED_ORIGINS=https://your-frontend.example
MAX_ROOMS=50
MAX_PLAYERS_PER_ROOM=16
MAX_CONN_PER_IP=8
TRUST_PROXY=0
COUNTDOWN_MS=3000
Replace the example frontend origin with the real origin of your application.
The server listens on 127.0.0.1, not 0.0.0.0. This means the game process accepts connections through the local machine rather than listening directly on every network interface.
Caddy will later receive public traffic and forward it to this local address.
We protected the environment file so that only root could read and modify it.
sudo chown root:root /etc/bloodstrike-game.env
sudo chmod 600 /etc/bloodstrike-game.env
This is especially important if secrets are added in the future.
Creating a systemd service
We wanted the game server to start automatically when the VPS boots and restart if the process fails.
We created a systemd unit at:
/etc/systemd/system/bloodstrike-game.service
Its essential configuration was:
[Unit]
Description=Bloodstrike Game Server
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/apps/cs-bloodstrike/apps/game-server
Environment=NODE_ENV=production
EnvironmentFile=/etc/bloodstrike-game.env
ExecStart=/usr/bin/node /home/ubuntu/apps/cs-bloodstrike/apps/game-server/dist/server.mjs
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
The service runs as ubuntu, not root. It loads its environment from the protected configuration file and starts the production bundle.
The restart settings tell systemd to restart the process if it exits because of a failure.
After creating the file, we loaded and enabled the service.
sudo systemctl daemon-reload
sudo systemctl enable --now bloodstrike-game
We checked its status.
sudo systemctl is-active bloodstrike-game
The expected output was:
active
We then repeated the health check.
curl -i http://127.0.0.1:8080/health
The application returned 200 OK.
Useful commands for maintenance
Once an application runs as a service, these commands make everyday maintenance easier.
Check the service:
sudo systemctl status bloodstrike-game --no-pager
Restart it after changing its environment:
sudo systemctl restart bloodstrike-game
Read recent logs:
sudo journalctl -u bloodstrike-game -n 100 --no-pager
Follow the logs live:
sudo journalctl -u bloodstrike-game -f
These commands help diagnose problems without manually launching the server.
Summary
We cloned the private repository, installed dependencies, built and tested the code, and deployed the production server as a systemd service.
The game server now runs in the background and listens locally on port 8080. It is not directly exposed to the public network.
In the next lesson, we will connect a domain to the VPS and configure HTTPS with Caddy.