DEV Community

Cover image for Deploy your app to a VPS with Docker, Nginx, and SSL
Vladimir Vovk
Vladimir Vovk

Posted on

Deploy your app to a VPS with Docker, Nginx, and SSL

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
Enter fullscreen mode Exit fullscreen mode

And update the system.

apt update && apt -y upgrade
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Check that we can log in with the SSH key.

ssh -i ~/.ssh/vps-key deploy@server-ip-address
Enter fullscreen mode Exit fullscreen mode

Next, let's disable root and password login. Open the config file:

sudo nano /etc/ssh/sshd_config
Enter fullscreen mode Exit fullscreen mode

And set:

PermitRootLogin no
PasswordAuthentication no
Enter fullscreen mode Exit fullscreen mode

Save the config file and restart the service.

sudo systemctl restart ssh
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install Docker

Use official Docker docs.

Add the deploy user to the docker group.

sudo usermod -aG docker $USER
newgrp docker
docker version
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

⚠️ 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 .
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

Pull the Image on the VPS

Log in to the server.

ssh -i ~/.ssh/vps-key deploy@server-ip-address`
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Use Nginx as a Reverse Proxy

Install Nginx.

sudo apt -y install nginx
sudo systemctl enable --now nginx
Enter fullscreen mode Exit fullscreen mode

Turn off the default config.

sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
Enter fullscreen mode Exit fullscreen mode

Create a new config for the app.

sudo nano /etc/nginx/sites-available/your-app-name
Enter fullscreen mode Exit fullscreen mode

⚠️ 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create the certificate.

sudo certbot --nginx -d your-app-domain.com -d www.your-app-domain.com
Enter fullscreen mode Exit fullscreen mode

That's it. The app is live now! 🥳

Credits

Photo by Kevin Ache on Unsplash

Top comments (0)