This guide takes an app from your computer to a VPS with a custom domain and HTTPS. You will build a Docker image locally, transfer it over SSH, run it with Docker Compose, and put Caddy in front of it. No container registry is required.
The example Dockerfile uses Node.js. The server-side steps also work for other applications with a production Docker image that serves HTTP on port 8080. Databases and persistent application storage need their own configuration and are outside this walkthrough.
- Before you start
- Create a Dockerfile
- Build and test the image locally
- Transfer the image to your VPS
- Run the app with Docker Compose
- Configure a domain and HTTPS with Caddy
- Verify and troubleshoot the deployment
- Deploy an update
- Automate deployment with QuickDeploy
Before you start
You need:
- Docker running on your computer, plus an SSH client. The shell examples use a POSIX shell, such as Bash or a WSL terminal.
- A Linux VPS with Docker and the Docker Compose plugin installed. Follow the Linux server setup guide if you need to prepare one.
- An SSH user that can run Docker on the VPS without an interactive sudo prompt.
- A domain you control, with an A record pointing to the VPS IPv4 address. If an AAAA record exists, it must point to an IPv6 address that reaches this same server.
- Inbound TCP ports 80 and 443 allowed by your server and provider firewalls. They must be available for Caddy. Keep SSH access allowed too.
Replace user@your-vps-ip with your SSH destination and app.example.com with your domain throughout. The app and container are named webapp in this example.
Use a server where these container names and proxy ports are available. If you already run a reverse proxy, connect the app to its network and extend that proxy's configuration instead of starting a second proxy on ports 80 and 443.
For complete framework-specific examples, use the Next.js recipe or SvelteKit recipe. Their internal port is 3000; adapt the app and proxy ports together rather than copying this guide's 8080 unchanged.
Create a Dockerfile
On your computer, open your app's root directory.
The Dockerfile below assumes a Node.js app with a committed package-lock.json, an npm run build script, and an npm start script that runs a production HTTP server. Configure that server to listen on 0.0.0.0:8080; some frameworks need explicit host and port arguments instead of the HOST and PORT environment variables. If your app uses another runtime or serves only static files, use its production Dockerfile and keep the deployment steps below.
Create Dockerfile:
FROM node:24-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=8080
USER node
EXPOSE 8080
CMD ["npm", "start"]
This example uses Node.js 24 LTS. Confirm that your app and dependencies support it. npm start must run your production server, not a development server. If your app does not have a build step, remove RUN npm run build.
Create .dockerignore alongside it so local dependencies, build output, and environment files are not copied into the image:
node_modules
.git
.env
.env.*
npm-debug.log*
.next
.nuxt
.output
.svelte-kit
dist
build
The image rebuilds the app from source. If your production build needs public configuration, provide it explicitly for that build; runtime environment variables cannot change values already embedded in browser JavaScript. Keep credentials out of the Dockerfile and image.
Build and test the image locally
On your computer, run this from the directory containing the Dockerfile. The final . is the build context:
docker build -t webapp:1.0.0 .
The image must match your VPS CPU architecture. If your computer uses ARM and the VPS uses x86-64, build with docker buildx build --platform linux/amd64 --load -t webapp:1.0.0 . instead. Use linux/arm64 for an ARM VPS. You can check the server with ssh user@your-vps-ip 'uname -m': x86_64 means AMD64, and aarch64 means ARM64. Testing an image built for a different architecture requires emulation on your computer.
Run the image, exposing it only on your computer's loopback interface:
docker run --rm --name webapp-local -p 127.0.0.1:8080:8080 webapp:1.0.0
If your app needs runtime variables, create a local environment file and add --env-file .env before webapp:1.0.0. Use development credentials for this local check.
Open http://localhost:8080 and test a real page or endpoint. If it fails, check the container output and confirm your app listens on 0.0.0.0:8080. EXPOSE 8080 documents a port; it does not configure your application to listen on it.
Stop the local container with Ctrl+C before continuing.
Transfer the image to your VPS
On your computer, send the image to Docker on the VPS:
docker save webapp:1.0.0 | ssh user@your-vps-ip 'docker load'
ssh user@your-vps-ip 'docker image inspect webapp:1.0.0'
The second command confirms that the image is available on the server; its Architecture field shows the target architecture. You can also use a container registry, but this guide uses SSH for the entire transfer.
Run the app with Docker Compose
Connect to your VPS:
ssh user@your-vps-ip
All commands from here through the verification section run on the VPS. Create the directories and the shared Docker network:
mkdir -p ~/webapp ~/caddy
docker network inspect web >/dev/null 2>&1 || docker network create web
cd ~/webapp
touch .env
chmod 600 .env
Add any production runtime variables to ~/webapp/.env as KEY=value lines. Leave it empty if your app does not need any. Do not commit this file to Git.
Create ~/webapp/docker-compose.yml:
services:
webapp:
container_name: webapp
image: webapp:1.0.0
env_file:
- .env
expose:
- "8080"
networks:
- web
restart: unless-stopped
networks:
web:
external: true
The web network is external because both Compose projects use it. Docker Compose will not create it, which is why we created it explicitly above. The app's port is reachable by containers on that network; it is not published on the VPS host.
Validate and start the app:
docker compose config --quiet
docker compose up -d
docker compose ps
docker compose logs --tail=50 webapp
Check that the app stays running and reports that it is listening on the expected port. It will not yet be reachable through your domain.
Configure a domain and HTTPS with Caddy
On the VPS, create ~/caddy/Caddyfile, replacing app.example.com with your domain:
app.example.com {
reverse_proxy webapp:8080
}
webapp resolves to the app container on the shared network. Do not use localhost:8080 here: inside the Caddy container, localhost refers to Caddy itself.
Create ~/caddy/docker-compose.yml:
services:
caddy:
container_name: caddy
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- web
restart: unless-stopped
networks:
web:
external: true
volumes:
caddy_data:
caddy_config:
The named volumes preserve Caddy's certificates and state across container replacements. Keep them when updating the proxy.
Validate the files and start Caddy:
cd ~/caddy
docker compose config --quiet
docker compose run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose up -d
docker compose logs --tail=50 caddy
For a later Caddyfile edit, validate the new configuration before reloading the existing proxy:
cd ~/caddy
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
Caddy obtains and renews HTTPS certificates automatically when the domain resolves to this server and the certificate authority can reach it. See Caddy's HTTPS requirements for details.
Verify and troubleshoot the deployment
Open https://app.example.com in your browser and test an actual app route. You can also check the response from the VPS:
curl --fail --show-error --location --output /dev/null --write-out '%{http_code}\n' https://app.example.com
If the route requires authentication, use a public health endpoint for this check instead. A running container alone does not prove that your app is serving requests correctly.
- Caddy returns 502: inspect
docker logs --tail=50 caddyanddocker logs --tail=50 webapp. Confirm both containers appear indocker network inspect web, and that the app listens on0.0.0.0:8080. - The browser times out or HTTPS fails: check DNS, any AAAA record, inbound TCP ports 80 and 443, and Caddy's logs. Confirm another service is not already using those ports.
- Compose reports that network
webdoes not exist: create it withdocker network create web, then rerun Compose. - The app exits or restarts: inspect its logs, production environment variables, start command, and image architecture.
For more context, see what commonly breaks during VPS deployments.
Deploy an update
Back on your computer, build and transfer a new image with a new tag. Use the same target platform as your first build:
docker build -t webapp:1.0.1 .
docker save webapp:1.0.1 | ssh user@your-vps-ip 'docker load'
On the VPS, change the app's image in ~/webapp/docker-compose.yml to webapp:1.0.1, then run:
cd ~/webapp
docker compose config --quiet
docker compose up -d
docker compose logs --tail=50 webapp
Repeat the browser check. This single-container setup can briefly interrupt requests while Compose replaces the app container. If the new version fails, change the image back to webapp:1.0.0 and run docker compose up -d again. Keep the old image until you have verified the update. Image rollback does not undo database migrations or other external state changes.
Automate deployment with QuickDeploy
QuickDeploy automates building and deploying your app, configuring Docker Compose, and setting up Caddy for your domain. To use it, follow the installation and configuration instructions and check the server requirements, then run this from your app directory:
quickdeploy push --domain app.example.com
QuickDeploy builds with Nixpacks; it does not consume the Dockerfile above. Treat this as an alternative deployment workflow. When moving from the manual setup above, plan the handover of ports 80 and 443 and your app's configuration before running QuickDeploy; an existing manually managed proxy already occupies those ports.
If your app needs a database, follow the full-stack app and PostgreSQL deployment guide for Docker networking, persistent storage, and production migrations.
See the deployment commands, GitHub Actions deployment guide, or QuickDeploy pricing and features for the next step.