Setup Docker on Windows: local image to HTTPS URL in 1 hour

By Mark Hayes
Setup Docker on Windows: local image to HTTPS URL in 1 hour

Here's the uncomfortable truth about most "setup Docker on Windows" tutorials: they end at hello-world. You get a green whale in the system tray, a container that prints a message, and absolutely no path to the thing you actually need, which is a URL you can paste into an email to your beta users.

If you're a solo founder with an app that works on localhost and a deadline measured in days, the setup itself is the easy part. The hard part is making sure the image you build on Windows behaves the same way on a Linux server, and then getting it onto one without spending a week learning VPCs, load balancers and certificate managers.

This guide does both. Budget about an hour: roughly 20 minutes to install and verify Docker, 20 minutes to make your image production-ready, and 20 minutes to get it live over HTTPS.

Before you start: what you need

  • Windows 11, or Windows 10 22H2 (64-bit). Home, Pro and Enterprise all work with the WSL 2 backend.
  • Hardware virtualization enabled. Open Task Manager, go to Performance > CPU, and check that Virtualization says Enabled. If it doesn't, turn on Intel VT-x or AMD-V (sometimes called SVM) in your BIOS/UEFI settings.
  • At least 8 GB of RAM. 16 GB is much more comfortable if you also run an IDE and a browser.
  • Admin rights on the machine for the install.
Docker Desktop is free for personal use, education, open source, and small businesses (fewer than 250 employees and under $10 million in annual revenue). A pre-seed startup fits comfortably, but check Docker's current terms if your situation is different.

Step 1: install WSL 2 (5 minutes)

Docker Desktop on Windows runs Linux containers inside a lightweight VM managed by the Windows Subsystem for Linux. Install WSL first so the Docker installer doesn't have to guess.

Open PowerShell as Administrator and run:

wsl --install

This enables the required Windows features and installs Ubuntu as the default distribution. Reboot when prompted, then finish creating your Linux user in the Ubuntu window that opens.

If WSL was already on the machine, make sure it's current and defaults to version 2:

wsl --update
wsl --set-default-version 2
wsl --status
wsl -l -v

wsl -l -v should show your distribution with VERSION 2. If it shows 1, convert it:

wsl --set-version Ubuntu 2

Step 2: install Docker Desktop (10 minutes)

You can download the installer from Docker's website, or install it from the command line with winget:

winget install -e --id Docker.DockerDesktop

During setup, keep Use WSL 2 instead of Hyper-V checked. When the install finishes, sign out and back in (or reboot), then start Docker Desktop and wait for the engine status to go green.

Two settings are worth changing right away:

  1. Settings > Resources > WSL Integration: enable integration for your Ubuntu distribution. This lets you run docker from inside WSL, which matters for the next section.
  2. Settings > General: decide whether you want Docker Desktop to start on login. If you build daily, turn it on.

Step 3: verify the install

Open a new terminal (PowerShell or your Ubuntu shell) and run:

docker version
docker compose version
docker run --rm hello-world

You want to see both a Client and a Server section in docker version. If you only see the client and an error like error during connect ... dockerDesktopLinuxEngine, the engine isn't running. Start Docker Desktop and try again.

Note that it's docker compose (with a space). The old standalone docker-compose binary is legacy. Compose v2 ships as a Docker CLI plugin and is included with Docker Desktop.

Step 4: configure Windows so it doesn't sabotage you

This is where most Windows-specific Docker pain comes from. None of it shows up in hello-world, and all of it shows up the first time you deploy.

Keep your code inside the WSL filesystem

Bind-mounting a project from C:\Users\you\code into a container goes through a translation layer between the Windows and Linux filesystems. It works, but file watching and npm install can be dramatically slower. Clone your repo inside WSL instead:

# inside the Ubuntu shell
mkdir -p ~/code && cd ~/code
git clone https://github.com/you/your-app.git
cd your-app
code .   # opens VS Code connected to WSL, if you use it

Fix line endings before they break your container

Git for Windows can convert LF to CRLF on checkout. That's harmless for most source files and fatal for shell scripts. An entrypoint.sh with Windows line endings fails inside a Linux container with an error like /usr/bin/env: 'bash\r': No such file or directory.

Add a .gitattributes file to the repo root so every clone behaves the same:

* text=auto eol=lf
*.bat text eol=crlf
*.ps1 text eol=crlf

And set your local Git to stop converting:

git config --global core.autocrlf input

Cap WSL's memory

By default the WSL VM can claim a large share of your RAM and is slow to give it back. Create C:\Users\\.wslconfig:

[wsl2]
memory=6GB
processors=4

Then run wsl --shutdown in PowerShell and restart Docker Desktop to apply it.

Watch out if you're on Windows on ARM

On a Snapdragon laptop, Docker builds linux/arm64 images by default. Most cloud servers run linux/amd64. Build explicitly for the target platform:

docker buildx build --platform linux/amd64 -t your-app:latest .

On a regular Intel or AMD machine you can skip this; your images are already amd64.

Step 5: make the image production-ready (20 minutes)

An image that runs on your laptop isn't automatically an image that runs in production. These are the changes that close the gap.

Listen on 0.0.0.0 and read PORT from the environment

Inside a container, localhost means "this container only." If your server binds to 127.0.0.1, the platform's router can't reach it. Also read the port from PORT rather than hardcoding it:

const port = Number(process.env.PORT) || 8080;
app.listen(port, "0.0.0.0");

The same idea applies to other stacks: gunicorn --bind 0.0.0.0:$PORT, uvicorn app:app --host 0.0.0.0 --port $PORT, and so on.

Write a lean multi-stage Dockerfile

Here's an example for a Node.js app. Adapt the base image and build steps for your stack.

# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 8080
CMD ["node", "dist/server.js"]

Add a .dockerignore

Without it, COPY . . drags your local node_modules, .git history and .env secrets into the image.

node_modules
.git
.env
.env.*
dist
*.log
Dockerfile
.dockerignore

Move config into environment variables

Database URLs, API keys, storage credentials, session secrets: none of them belong in the image. Put local values in a .env file that's in .gitignore, and set production values on the hosting platform.

Test it the way production will run it

Build the image and run it with no bind mounts, a production-like env file and a non-default port. If it works like this, it'll work in the cloud.

docker build -t your-app:latest .
docker run --rm -p 8080:8080 -e PORT=8080 --env-file .env your-app:latest

Open http://localhost:8080. If your app depends on Postgres, run it alongside with a minimal compose.yaml for local testing:

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: app
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

docker compose up -d db gives you a local database. In production you'll use a managed one instead, so treat this file as a dev tool, not a deployment plan.

Step 6: from local image to live HTTPS URL (20 minutes)

This is the part the usual guides skip, and it's where founders lose weeks. The traditional route looks like this: create a VM, install Docker on it, open firewall ports, set up Nginx as a reverse proxy, configure Certbot for TLS, point DNS, then figure out backups for the database you're now running by hand. Each step is learnable. Together they're a week you don't have.

The shortcut is a platform that runs your container and handles routing and TLS for you. With Granite.so the flow is:

1. Get your code or image where the platform can reach it. Either push your repository to GitHub and connect it, so Granite.so builds from your Dockerfile and redeploys on every push, or push your built image to a container registry and deploy that image. For example, with GitHub Container Registry:

echo $GITHUB_TOKEN | docker login ghcr.io -u your-username --password-stdin
docker tag your-app:latest ghcr.io/your-username/your-app:latest
docker push ghcr.io/your-username/your-app:latest

Use a personal access token with the write:packages scope, stored in an environment variable, not typed into the command.

2. Create the app from the repo or image in the Granite.so dashboard.

3. Set environment variables: the same keys you had in .env, with production values. Make sure the port your app listens on matches the one configured for the service; reading it from PORT keeps this painless.

4. Attach a managed database if your app needs one. You get a connection string to set as DATABASE_URL, and backups are handled for you. If you store user uploads, add S3-compatible storage, which comes with a CDN.

5. Deploy. When the build finishes and the app passes its checks, you get a live HTTPS URL. No VPC, no load balancer config, no certificate renewal cron.

6. Optional: add your own domain and update the DNS record the dashboard shows you.

The Hobby plan is $9 a month and includes $15 of usage credits, which is plenty for a beta with a handful of users. If you connected Git, your deploy loop from here on is just git push.

Test your production URL in a private browser window before you send it to anyone. It catches the missing env var that your logged-in local session was hiding.

Troubleshooting the common failures

SymptomLikely causeFix
Docker Desktop says WSL is too old or failed to startOutdated WSL kernelwsl --update, then restart Docker Desktop
"Virtualization support not detected"VT-x/AMD-V disabled in firmwareEnable it in BIOS/UEFI
docker works in PowerShell but not in UbuntuWSL integration offSettings > Resources > WSL Integration
bash\r: No such file or directoryCRLF line endings in a script.gitattributes with eol=lf, re-checkout the file
exec format error in the cloudImage built for arm64docker buildx build --platform linux/amd64
App runs locally, 502 in the cloudBound to 127.0.0.1 or wrong portBind 0.0.0.0, read PORT
Builds are painfully slowProject on /mnt/cMove the repo into the WSL filesystem

Your one-hour checklist

  • Virtualization enabled, wsl --install done, wsl -l -v shows version 2
  • Docker Desktop installed with the WSL 2 backend
  • docker version shows client and server; hello-world runs
  • Repo lives in the WSL filesystem, .gitattributes enforces LF
  • App binds 0.0.0.0 and reads PORT
  • Multi-stage Dockerfile and .dockerignore in place
  • Image runs locally with --env-file and no bind mounts
  • Repo connected (or image pushed) and deployed with env vars set
  • HTTPS URL tested in a private window

Ship the URL, not the setup

Getting Docker set up on Windows is a solved problem. The part that eats runway is everything between a working container and a URL a customer can open. Do the setup properly once (WSL 2, LF line endings, the right architecture, a clean image) and that gap shrinks to a push.

If you'd rather spend this week on customer feedback than on Nginx configs, deploy your image on Granite.so and send your beta users a real link today.

Keep reading

More posts tagged “Tutorials”.
Be first in line for updates
and special pricing
Get early access to new features and exclusive discounts delivered straight to your inbox