Simple Way to Deploy Your Services on a VPS Using Docker
By the end of this guide, you'll confidently run your applications on a VPS using Docker's power and simplicity, complete with a 'one-click' deployment script for instant updates.
Introduction
You've got your VPS up and running, but now comes the real challenge: deploying your web app. With the amount of options out there, it's easy to get lost in the possibilities.
This guide will walk you through a battle-tested method using Docker. We'll cover the essentials:
Docker and container basics
Building your own Docker images
Deploying containers on your VPS
I've designed this tutorial to be hands-on, so feel free to follow along. Whether you're a seasoned developer or just getting started, I think you’ll find value in adopting this deployment strategy. Let's dive in.
Docker: Swiss Army Knife of self-hosting
What is Docker?
Docker is a containerization platform that facilitates application deployment. It packages an application with its code, dependencies, and settings into a container, ensuring consistent environments across various platforms - from your local machine to your VPS.
Why Docker?
As an indie hacker, you might think you need a complex stack of deployment tools, but Docker alone can take you surprisingly far. This battle-tested containerization platform offers everything most solo developers need:
Dead-simple deployment: One command to package and run your entire app
Zero cost: Completely free and open-source
Self-healing services: Automatic restarts when things crash
Built-in logging: All your logs in one place, no extra tools needed
Performance insights: Monitor memory and CPU usage out of the box
Resource control: Set limits to keep your services from misbehaving
Before reaching for Coolify or other “all-in-one” tools, remember that Docker by itself can handle most deployment scenarios you'll encounter as a solo developer. Keep it simple, ship faster.
Setting Up Docker
You'll need Docker on both your local machine and VPS. Let's get started.
Installation on your local machine:
Visit the official Docker website
Follow the installation guide for your OS
Verify the installation:
docker --version
Installation on your VPS (Ubuntu):
Follow Digital Ocean's guide (steps 1 & 2)
Verify the installation:
docker run hello-world
You should see a "Hello from Docker!" message, confirming a successful setup.
If your VPS is not running Ubuntu, check out the official installation guide for your operating system.
Building your Docker image
From code to image
Remember the hello-world
container you ran earlier? That was a running instance of a Docker image. An image is the blueprint in Docker, containing your application code and everything it needs to run.
Let's create an image for a web app. We'll use a simple Go web server, but the same principles apply to any language. The beauty of Docker is that once you have an image, the deployment process is identical regardless of the underlying programming language used.
Feel free to follow along with Go - just make sure you have it installed first. Alternatively, use a language of your choice. If you choose to use a different language, some steps during image creation might differ slightly.
To setup your service, start by creating a directory on your local machine:
mkdir app
cd app
Initialize a Go module:
go mod init app
Create a main.go
file with a basic HTTP server:
package main
import (
"fmt"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
}
func main() {
http.HandleFunc("/", helloHandler)
fmt.Println("Server is listening on :8080...")
http.ListenAndServe(":8080", nil)
}
Test your server:
go run main.go
You should see "Server is listening on :8080...". Once confirmed, kill the server. Now we're ready to create an image out of this.
Creating an image
There are two primary methods to create a Docker image: manually defining a Dockerfile or using a higher-level tool like nixpacks. Let's explore both options.
Option 1: The Dockerfile approach
This method is more hands-on and language-specific, but it often results in faster builds and smaller image sizes.
On your local machine, create a Dockerfile in your app directory:
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY *.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -o app
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/app ./
EXPOSE 8080
CMD ["./app"]
This Dockerfile uses a multi-stage build to create a compact image. It exposes port 8080, matching our web server's configuration.
Now that you have a Dockerfile, build the image:
docker build --platform linux/amd64 -t app:latest .
We specify the linux/amd64
platform to ensure compatibility with our VPS.
Verify the image:
docker images
You should see output similar to:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 3875e8cecccf 5 seconds ago 8.2MB
(Optional) Test the image:
docker run -it app
You should see the "Server is listening..." output. Note that this may not work on your local machine if your architecture differs from linux/amd64
.
If you're interested in an alternative approach, continue to the next section about nixpacks. Otherwise, feel free to skip ahead to learn how to run this on your VPS.
Option 2: Simplifying with nixpacks
If you’re looking for a more streamlined approach, nixpacks offers an abstraction layer that eliminates the need for a Dockerfile, simplifying the image-building process.
Install nixpacks (for macOS users):
brew install nixpacks
For other operating systems, consult the official nixpacks website.
Build your image:
CGO_ENABLED=0 nixpacks build . --platform linux/amd64 --name app
Note: the
CGO_ENABLED=0
is only required if you’re building a Go app.
Verify the image:
docker images
You should see output similar to:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 7ca012c83587 9 seconds ago 33.7MB
Comparing approaches
If you've followed both options, you'll notice the nixpacks image size is significantly larger (about 300% in this case). This illustrates the trade-off: nixpacks offers simplicity and language agnosticism, while a custom Dockerfile allows for optimization at the cost of complexity.
(Optional) Test the image:
docker run -it app
You should see the "Server is listening..." output. Note that this may not work if your local machine architecture differs from linux/amd64
.
Deployment: bring it all together
Docker Compose: your orchestration friend
Docker Compose extends Docker's functionality by managing single and multi-container applications. Using a single YAML file, it lets you define, connect, and run multiple services in one go. It also handles the entire container lifecycle - from startup and shutdown to networking and volume management. Since it comes bundled with Docker, we'll use it to streamline our application setup and deployment.
On your VPS, create a compose.yaml
file:
services:
app:
image: app:latest
container_name: app
ports:
- "8080:8080"
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256MB
This configuration maps port 8080 from the host to the container and sets CPU and memory resource limits. While these latter ones are optional, they prevent a single container from hogging your entire VPS resources.
With this file, all we need is the Docker image present on our VPS.
Transferring your image to the VPS
To get your locally-built image onto your VPS, copy the image over using SSH. On your local machine:
docker save app:latest | gzip | ssh user@12.34.56.78 docker load
Note: This may take some time depending on your image size and network speed.
Verify the transfer:
ssh user@12.34.56.78
docker images
You should see output similar to:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 5dd6c8c8a032 7 minutes ago 15.8MB
Now that all the pieces are in place, let's get your application up and running.
Launching your Dockerized app
On your VPS, navigate to the directory containing your compose.yaml
file and run:
docker compose up -d
The
-d
flag runs it in detached mode, allowing you to continue using the terminal.
Verify the running container:
docker ps
You should see output similar to:
CONTAINER ID IMAGE COMMAND PORTS NAMES
17bec024dcce app:latest "./app" 0.0.0.0:8080->8080/tcp app
Monitor your app by viewing logs:
docker logs app
Or by checking its resource usage:
docker stats app
Test the service is running:
curl localhost:8080
Expected output:
Hello, World!
Streamlining deployments
To simplify future updates, create a deployment script deploy.sh
on your local machine:
#!/bin/bash
docker build --platform linux/amd64 -t app:latest .
docker save app:latest | gzip | ssh user@12.34.56.78 docker load
ssh user@12.34.56.78 docker compose up -d app
Note: You can substitute the Docker build command with nixpacks if preferred.
Make the script executable:
chmod +x deploy.sh
Whenever you’re ready to deploy, run the script:
deploy.sh
This script encapsulates the entire deployment process - building, transferring, and launching your updated application.
Congratulations! You've successfully set up a streamlined Docker deployment pipeline for your VPS. With this foundation, you can easily manage and update your application as it evolves.
An alternative way to manage deployments is through Docker contexts, which leverages the platform's layered architecture and results in smaller file transfers over the network. I'll explore this approach in depth in a future article.
The issue with ufw
If you've set up your VPS security following my previous article, you're probably using ufw (Uncomplicated Firewall). Here's a crucial security note: Docker bypasses ufw rules by default. This means ports you thought were protected might actually be exposed to the internet - for example, port 8080 could be accessible even if ufw blocks it.
But don't worry - there's a straightforward fix for this behaviour. Edit the ufw rules file:
sudo vim /etc/ufw/after.rules
Add the following rules at the end of the file:
# BEGIN UFW AND DOCKER
*filter
:ufw-user-forward - [0:0]
:ufw-docker-logging-deny - [0:0]
:DOCKER-USER - [0:0]
-A DOCKER-USER -j ufw-user-forward
-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16
-A DOCKER-USER -p udp -m udp --sport 53 --dport 1024:65535 -j RETURN
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 192.168.0.0/16
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 192.168.0.0/16
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 172.16.0.0/12
-A DOCKER-USER -j RETURN
-A ufw-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] "
-A ufw-docker-logging-deny -j DROP
COMMIT
# END UFW AND DOCKER
Save and close the file. Reload ufw:
sudo ufw reload
With this fix in place, your VPS should no longer be exposing port 8080. This means that your web server is no longer accessible.
To make it accessible, let's expose it on the standard HTTP port. Update your compose.yaml
file to map the host port to 80
:
services:
app:
image: app:latest
container_name: app
ports:
- "80:8080" <-- this line changed
... (resource config omitted)
Restart your app:
docker compose up -d
Your app should now be accessible on port 80 using the VPS public ip address, assuming your firewall allows it.
Check your firewall's allowed ports with
sudo ufw status
. If port 80 isn't listed, enable it by runningsudo ufw allow 'Nginx Full'
.
Future improvements
While our web server is functional, it's exposed directly to the internet - not the best practice for production deployments. A better approach is placing it behind a battle-tested reverse proxy, which offers three major benefits:
Better security: Acts as a shield between your application and the internet
Simple HTTPS setup: Makes SSL/TLS implementation straightforward
Flexible routing: Route different paths to different web servers (like
api.yourdomain.com
to your API server andapp.yourdomain.com
to your frontend)
Next week, I'll guide you through setting this up with nginx. You'll learn how to configure it with your Docker containers and secure everything using HTTPS with LetsEncrypt certificates - all explained step by step.
Congratulations
Pat yourself on the back for reaching this far! You've hit some major milestones:
Set up Docker from scratch
Containerized your application
Learned Docker Compose
Deployed to your VPS
You've built a solid foundation for production deployments. See you in the next guide, where we'll take it even further!
Very good content, thank you for putting that up