Update VPS
We will need a Debian/Ubuntu VPS. First, let's connect to our server.
ssh root@server-ip-address
# E.g., ssh root@1.2.3.4
And update the system.
apt update && apt -y upgrade
SSH-key login
Now let's create a separate deploy user to run our app (using the root superuser is not the best idea).
adduser deploy
usermod -aG sudo deploy
A more secure way to log in to a server is using an SSH key. Let's create one on the "local" machine.
cd ~/.ssh
ssh-keygen -t ed25519 -f vps-key
And copy it to the server (use brew install ssh-copy-id to install ssh-copy-id on Mac).
ssh-copy-id -i vps-key deploy@server-ip-address
Check that we can log in with the SSH key.
ssh -i ~/.ssh/vps-key deploy@server-ip-address
Next, let's disable root and password login. Open the config file:
sudo nano /etc/ssh/sshd_config
And set:
PermitRootLogin no
PasswordAuthentication no
Save the config file and restart the service.
sudo systemctl restart ssh
Turn on Firewall (UFW)
Only allow SSH, HTTP, and HTTPS.
sudo apt -y install ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
Install Docker
Use official Docker docs.
Add the deploy user to the docker group.
sudo usermod -aG docker $USER
newgrp docker
docker version
Create App Docker Image
Before we can run our app with Docker on the server, we need to "pack" it into a Docker image. Go to your project folder on your local machine, and create a Dockerfile. Search for a Dockerfile for your language/framework. For example, a Node.js app Dockerfile could look something like this.
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
# This Dockerfile uses Docker Hardened Images (DHI) for enhanced security.
# For more information, see https://docs.docker.com/dhi/
# Builder stage: install all dependencies and compile TypeScript.
FROM dhi.io/node:24-alpine3.23-dev AS builder
WORKDIR /app
# Install dependencies as a separate step to take advantage of Docker's
# caching. Leverage a cache mount to /root/.npm to speed up subsequent
# builds. Leverage a bind mount to package.json to avoid having to copy
# it into this layer.
RUN --mount=type=cache,target=/root/.npm \
--mount=type=bind,source=package.json,target=package.json \
npm install
# Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files:
# RUN --mount=type=cache,target=/root/.npm \
# --mount=type=bind,source=package.json,target=package.json \
# --mount=type=bind,source=package-lock.json,target=package-lock.json \
# npm ci
# Copy the source code into the container and compile TypeScript.
COPY . .
RUN npm run build
# Deps stage: install production dependencies only.
FROM dhi.io/node:24-alpine3.23-dev AS deps
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm \
--mount=type=bind,source=package.json,target=package.json \
npm install --omit=dev
# Once you create a package-lock.json by running npm install locally, switch to npm ci and bind both files:
# RUN --mount=type=cache,target=/root/.npm \
# --mount=type=bind,source=package.json,target=package.json \
# --mount=type=bind,source=package-lock.json,target=package-lock.json \
# npm ci --omit=dev
# Runner stage: minimal runtime image with compiled app and production deps.
FROM dhi.io/node:24-alpine3.23 AS runner
ENV PATH=/app/node_modules/.bin:$PATH
WORKDIR /app
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
# Expose the port that the application listens on.
EXPOSE 3000
# Run the application.
CMD ["node", "dist/index.js"]
Now check that Docker is installed on your local machine, and build the app Docker image.
docker buildx build --platform linux/amd64 -t my-app:latest
Notice the --platform linux/amd64 flag since we are building for our Linux server.
GitHub Container Registry
Great, we just created our app Docker image on our local machine. But how will we "copy" it to the server?
With Docker, we can push and pull images to the "image registry". Let's use GitHub Container Registry (ghcr.io).
Create a GitHub token to be able to push Docker images to ghcr.io
- Log in to github.com.
- Click on the "Profile Icon" (the top right corner) -> select
Settings. - Then
Credentials(on the left side) ->Personal access tokens (classic)(on the right side) - Create a new token with:
-
write:packages- push images -
read:packages- pull images -
delete:packages- optional
-
Login to ghcr.io
echo "your-github-token" | docker login ghcr.io -u your-github-username --password-stdin
⚠️ Change your-github-token and your-github-username to real values.
Rebuild the Image
We need to rebuild the app image using the proper image name.
docker buildx build --platform linux/amd64 -t ghcr.io/your-github-username/your-app-name:latest .
⚠️ Change your-github-username and your-app-name to real values.
Push the Image
docker push --all-tags ghcr.io/your-github-user-name/your-app-name
Pull the Image on the VPS
Log in to the server.
ssh -i ~/.ssh/vps-key deploy@server-ip-address`
Log in to ghcr.io.
echo "your-github-token" | docker login ghcr.io -u your-github-username --password-stdin
docker pull ghcr.io/geshela/gesheru_bot:latest
⚠️ Change your-github-username and your-app-name to real values.
Run the App
docker run -d --name your-app-name --restart unless-stopped -p 127.0.0.1:3000:3000 ghcr.io/your-github-username/your-app-name
⚠️ Change your-github-username and your-app-name to real values.
Check if the app container is running.
docker ps
docker logs -f --tail your-app-name
P.S. To restart the app.
docker stop your-app-name
docker rm your-app-name
docker run -d --name your-app-name --restart unless-stopped -p 127.0.0.1:3000:3000 ghcr.io/your-github-username/your-app-name
Use Nginx as a Reverse Proxy
Install Nginx.
sudo apt -y install nginx
sudo systemctl enable --now nginx
Turn off the default config.
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
Create a new config for the app.
sudo nano /etc/nginx/sites-available/your-app-name
⚠️ Change your-app-domain.com to the real value.
server {
listen 80;
server_name your-app-domain.com www.your-app-domain.com;
client_max_body_size 25m;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}
Let's enable the config.
sudo ln -s /etc/nginx/sites-available/your-app-name /etc/nginx/sites-enabled/your-app-name
sudo nginx -t
sudo systemctl reload nginx
Set up the DNS A Record
This is necessary so that the domain knows which IP address it should be linked to.
Log in to your domain registrar's admin panel. Add the A record. Enter the IP address of your server.
Add HTTPS with Let's Encrypt
Install Certbot.
sudo apt -y install certbot python3-certbot-nginx
Create the certificate.
sudo certbot --nginx -d your-app-domain.com -d www.your-app-domain.com
That's it. The app is live now! 🥳
Credits
Photo by Kevin Ache on Unsplash
Top comments (0)