# How Do I Set Up a .NET Dev Environment in a Cloud Sandbox?

> A new sandbox has no .NET. Microsoft's dotnet-install.sh put SDK 10.0.403 in ~/.dotnet in 10 seconds. A first build of a real ASP.NET Core solution took 22 seconds on small; from a named snapshot with the NuGet cache and bin/obj, it took 2.7 seconds. Raise the inotify limit before running two dotnet watch sessions.

Source: https://sandboxer.com/guides/dotnet-dev-environment-in-a-cloud-sandbox. Updated 2026-10-27.

**Short answer:** A new sandbox has no .NET. Microsoft's dotnet-install.sh put SDK 10.0.403 in ~/.dotnet in 10 seconds. A first build of a real ASP.NET Core solution took 22 seconds on small; from a named snapshot with the NuGet cache and bin/obj, it took 2.7 seconds. Raise the inotify limit before running two dotnet watch sessions.

We ran every command on this page on prod on 2026-10-27 between 12:03 and 14:15 UTC, on `small` sandboxes (2 vCPU, 4 GB) in eu-central. The project is [ardalis/CleanArchitecture](https://github.com/ardalis/CleanArchitecture) at commit `e7c79be` (2026-09-20): an ASP.NET Core 10 solution with six `src` projects, four test projects, central package management and `TreatWarningsAsErrors` on.

## Results

| Step | Time on `small` |
| - | - |
| Install SDK 10.0.403 with `dotnet-install.sh` (624 MB) | 9.9 s |
| `dotnet restore`, empty package folder, warm HTTP cache | 9.6 s |
| `dotnet build`, first | 21.7 s |
| `dotnet build`, nothing changed, build servers running | 2.3 s |
| `dotnet build`, one file in the Core project changed | 3.8 s |
| `dotnet build`, nothing changed, after `dotnet build-server shutdown` | 3.8 s |
| Unit tests (12) / integration tests (4), `--no-build` | 2.5 s / 2.8 s |
| `dotnet restore` + `dotnet build`, warm package folder, no `bin`/`obj` | 1.9 + 19.2 s |
| `dotnet watch`: start to first `200` | about 12 s |
| `dotnet watch`: hot reload of a method body | 1.4 s |
| From the named snapshot: restore / build / both test projects | 1.8 / **2.7** / 4.8 s |
| From the named snapshot: restore + build with `bin`/`obj` removed | 15.4 s |

The lesson: in .NET, the NuGet cache is big but cheap to refill; the compile is what you wait for. A warm package folder saved only a few seconds of restore, and the build still took 15 to 19 seconds. Keep `bin` and `obj` in the snapshot too, and MSBuild finds almost nothing to do.

## 1. Check what the sandbox has

```bash
sbx new --name dotnetdev-1 --type small
sbx exec sb_0dsmmxal12 bash -lc "command -v dotnet || echo no-dotnet; grep PRETTY /etc/os-release"
```

```text
no-dotnet
PRETTY_NAME="Ubuntu 24.04.5 LTS"
```

There is no .NET in the image on 2026-10-27; the rest is listed in [What's pre-installed in a sandbox](/guides/whats-preinstalled-in-a-sandbox). Ubuntu's own archive has SDK packages. After `sudo apt-get update`:

```text
dotnet-sdk-8.0:
  Candidate: 8.0.132-0ubuntu1~24.04.1
dotnet-sdk-10.0:
  Candidate: 10.0.113-0ubuntu1~24.04.1
```

Those are the 1xx feature band. Microsoft's current SDK on 2026-10-27 was 10.0.403 (runtime 10.0.14, LTS), from the [official release index](https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/releases-index.json), dated 2026-10-25. Feature bands matter because of `global.json`. With the default `rollForward` of `latestPatch`, a repo that pins `10.0.403` will not accept a 10.0.1xx SDK ([global.json rules](https://learn.microsoft.com/dotnet/core/tools/global-json)). We read the apt candidates; we did not install them.

## 2. Install the current SDK into your home directory

[Microsoft's install script](https://learn.microsoft.com/dotnet/core/tools/dotnet-install-script) installs without root and lets you pick a channel or an exact version:

```bash
sbx exec sb_0dsmmxal12 --timeout 300 bash -lc '
  curl -sSLo dotnet-install.sh https://dot.net/v1/dotnet-install.sh && chmod +x dotnet-install.sh
  ./dotnet-install.sh --channel 10.0 | tail -1
  cat >> ~/.profile <<"EOF"
export DOTNET_ROOT=$HOME/.dotnet
export PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools
export DOTNET_CLI_TELEMETRY_OPTOUT=1
EOF'
sbx exec sb_0dsmmxal12 bash -lc "dotnet --list-sdks; dotnet --list-runtimes"
```

```text
dotnet-install: Installation finished successfully.
10.0.403 [/home/user/.dotnet/sdk]
Microsoft.AspNetCore.App 10.0.14 [/home/user/.dotnet/shared/Microsoft.AspNetCore.App]
Microsoft.NETCore.App 10.0.14 [/home/user/.dotnet/shared/Microsoft.NETCore.App]
```

The install took 9.9 seconds and used 624 MB. Everything sits under `/home/user`, so it survives `sbx stop` and goes into a named snapshot. To match a repo exactly, use `./dotnet-install.sh --jsonfile global.json` or `--version 10.0.403`. `sbx exec ... bash -lc` reads `~/.profile`, so later commands find `dotnet` on `PATH`.

## 3. Restore and build a real solution

```bash
sbx exec sb_0dsmmxal12 --timeout 300 bash -lc "git clone -q https://github.com/ardalis/CleanArchitecture && cd CleanArchitecture && git checkout -q e7c79be54ba0 && dotnet restore Clean.Architecture.slnx"
```

At this commit, restore failed on 2026-10-27:

```text
src/Clean.Architecture.Web/Clean.Architecture.Web.csproj : error NU1903: Warning As Error: Package 'System.Security.Cryptography.Pkcs' 10.0.0 has a known high severity vulnerability
tests/Clean.Architecture.IntegrationTests/Clean.Architecture.IntegrationTests.csproj : error NU1903: Warning As Error: Package 'SSH.NET' 2025.1.0 has a known high severity vulnerability
…
```

This is [NuGet audit](https://learn.microsoft.com/nuget/concepts/auditing-packages), not the sandbox. Restore checks every package against advisories published after the commit was made. The repo sets `TreatWarningsAsErrors`, so the NU1902 and NU1903 warnings become errors. Any pinned .NET commit can start failing like this months later. The real fix is to update the packages. To build the commit as it is, keep those two codes as warnings:

```bash
export WarningsNotAsErrors="NU1902;NU1903"
```

MSBuild reads environment variables as properties, so this works for `restore`, `build`, `test` and `watch` without editing the repo. We ran the timed steps with the same value passed as `-p:WarningsNotAsErrors=...`. Because they run past the 30-second default of `sbx exec`, we used `--detach` and read the log:

```text
== restore, empty NuGet cache
9.580
2.1G	/home/user/.nuget/packages
245
249 MB /home/user/.nuget/packages/kubernetesclient
172 MB /home/user/.nuget/packages/aspire.hosting.orchestration.linux-x64
158 MB /home/user/.nuget/packages/grpc.tools
== build, first
21.725
    15 Warning(s)
    0 Error(s)
== build, nothing changed, build servers warm
2.337
== build, one file in Core changed
3.826
== processes left after build
247 MB /home/user/.dotnet/dotnet /home/user/.dotnet/sdk/10.0.403/MSBuild.dll
```

Three things to know:

- **There are two NuGet caches.** `~/.nuget/packages` (2.1 GB, 245 packages) is what builds read. `~/.local/share/NuGet/http-cache` (468 MB here) holds the downloads. Our 9.6-second restore had an empty package folder but a warm HTTP cache from the failed first attempt, which had taken 19 seconds to download everything. The Aspire and Kubernetes packages are most of the size.
- **Build servers stay in memory.** After a build, an MSBuild node (247 MB) and the Roslyn compiler server keep running, which is why the no-change build took 2.3 seconds. On a 4 GB `small`, run `dotnet build-server shutdown` when you need the memory back.
- **Tests that need Docker images.** The unit and integration tests passed (12 and 4). The functional tests start SQL Server through [Testcontainers](https://dotnet.testcontainers.org/), which pulls a SQL Server image; we did not run them. Docker is installed in the sandbox.

```text
== unit tests
Passed!  - Failed:     0, Passed:    12, Skipped:     0, Total:    12, Duration: 86 ms - Clean.Architecture.UnitTests.dll (net10.0)
== integration tests
Passed!  - Failed:     0, Passed:     4, Skipped:     0, Total:     4, Duration: 716 ms - Clean.Architecture.IntegrationTests.dll (net10.0)
```

## 4. dotnet watch on a private URL

The web project's launch profile wants HTTPS on `localhost:57679`, which needs a dev certificate. Skip the profile and listen on plain HTTP on `127.0.0.1`. The sandbox's public IPv6 firewall (`ufw`) is closed except SSH by default, and binding to `127.0.0.1` keeps the port private whatever the firewall does. Sandboxer's proxy adds TLS in front:

```bash
# ~/watch.sh in the sandbox
cd ~/CleanArchitecture
export ASPNETCORE_FORWARDEDHEADERS_ENABLED=true WarningsNotAsErrors="NU1902;NU1903" ASPNETCORE_ENVIRONMENT=Development DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER=1
exec dotnet watch --project src/Clean.Architecture.Web run --no-launch-profile --urls http://127.0.0.1:5000
```

```bash
sbx exec sb_0dsmmxal12 --detach "bash -l watch.sh > watch.log 2>&1"
sbx host sb_0dsmmxal12 5000 --localhost --title "dotnet watch"
```

```text
https://golden-solar-ginger-5000.box.sandboxer.com?_token=<token>
```

The app answered on `127.0.0.1:5000` about 12 seconds after the start. In Development it creates and seeds a SQLite file, `database.sqlite`. Through the hosted URL, a request without the token got `401`. The token link got `302` in 0.20 seconds and set a cookie, and with the cookie `GET /Contributors` returned `200` with JSON in 0.21 seconds.

**Set `ASPNETCORE_FORWARDEDHEADERS_ENABLED`.** The proxy ends TLS and talks plain HTTP to the app. Without the variable, the app built its pagination `Link` header with the wrong scheme:

```text
link: <http://golden-solar-ginger-5000.box.sandboxer.com/Contributors?page=2&per_page=3>; rel="next", …
```

With the variable set, ASP.NET Core [reads `X-Forwarded-Proto`](https://learn.microsoft.com/aspnet/core/host-and-deploy/proxy-load-balancer) and the same request returned `https://golden-solar-ginger-5000.box.sandboxer.com/Contributors?page=2&per_page=3`. The same applies to redirects, OAuth callback URLs and anything else that builds absolute URLs.

**Hot reload worked.** We added one line to `HandleAsync` in `Contributors/List.cs`, setting an `X-Edited` response header. The log showed:

```text
dotnet watch ⌚ File updated: ./src/Clean.Architecture.Web/Contributors/List.cs
dotnet watch 🔥 C# and Razor changes applied in 1427ms.
```

The next request through the hosted URL carried `x-edited: hot` without an app restart. The log also showed a `Failed to read '…/obj\Debug/net10.0/staticwebassets.development.json'` line with a Windows-style path. It did not stop the app or hot reload. With `dotnet watch` running, the sandbox used 1.1 GB of its 3.9 GB.

## 5. Raise the inotify limit before a second watcher

`dotnet watch` on this solution held 72 inotify instances. The sandbox allows 128 per user. A second `dotnet watch` on the web project, for another port or another branch, crashed:

```text
dotnet watch ❌ An unexpected error occurred: System.IO.IOException: The configured user limit (128) on the number of inotify instances has been reached, or the per-process limit on the number of open file descriptors has been reached.
```

Raise the limit, and write it to `/etc/sysctl.d` so it is still set after a boot from disk:

```bash
echo fs.inotify.max_user_instances=1024 | sudo tee /etc/sysctl.d/60-inotify.conf
sudo sysctl --system
```

With 1024, both watchers ran at once (146 instances in use), and the second answered `200` on port 5001. The value was still 1024 in a new sandbox made from the snapshot. An editor that watches files over SSH, such as VS Code with the C# extension, uses instances from the same limit. We did not test an editor.

**Stopping `dotnet watch`.** Started with `--detach`, it ignored `SIGTERM` and `SIGINT` in our test. `sbx exec --kill <pid>` ended the `bash` wrapper but left the watcher and the app running, still holding their inotify instances. This stopped it:

```bash
pkill -KILL -f "[d]otnet watch|[D]otnetTools/dotnet-watch|[C]lean.Architecture.Web"
```

The brackets matter when you send it through `sbx exec`. A plain `pkill -f "dotnet watch"` also matched the shell running the command and killed that first, so nothing else happened. For a server that should stay up, use a systemd unit as in [Run a service that comes back after resume](/guides/run-a-service-that-comes-back-after-resume).

## 6. Save a named snapshot and start from it

Stop the watcher and the build servers first. A snapshot keeps files, not processes:

```bash
sbx exec sb_0dsmmxal12 "dotnet build-server shutdown"
sbx snapshot sb_0dsmmxal12 dotnetdev-cache
sbx new --name dotnetdev-2 --type small --from dotnetdev-cache
```

The save took 2.1 seconds and the new sandbox was ready in 3.2 seconds. It held the SDK, both NuGet caches, the clone with `bin` and `obj`, and the inotify setting. In the new sandbox:

```text
10.0.403
1024
== restore, as saved
1.803
== build, as saved, servers cold
2.725
== unit + integration tests, as saved
4.802
Passed!  - Failed:     0, Passed:    12, Skipped:     0, Total:    12, Duration:
Passed!  - Failed:     0, Passed:     4, Skipped:     0, Total:     4, Duration:
```

Then we removed every `bin` and `obj` directory and ran restore and build again: 15.4 seconds. That is close to the 21.1 seconds (1.9 + 19.2) in the first sandbox, so the package cache alone saves little. Plan for about 3.4 GB under `/home/user`: 2.1 GB of packages, 625 MB of SDK and 627 MB for the built clone. To name the snapshot by a hash of `Directory.Packages.props` and rebuild it when packages change, follow [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot).

## FAQ

### Does a Sandboxer sandbox come with .NET?

No, not on 2026-10-27. Install it with Microsoft's `dotnet-install.sh` (SDK 10.0.403 in about 10 seconds, no root), or from Ubuntu's archive (`dotnet-sdk-10.0` 10.0.113 at the time). Check `dotnet --list-sdks` against the repo's `global.json`.

### Why does dotnet restore fail with NU1903 on an old commit?

NuGet audit flags packages with advisories published after the commit. In a repo with `TreatWarningsAsErrors`, that fails restore. Update the packages, or set `WarningsNotAsErrors="NU1902;NU1903"` to build the commit as it is.

### Why does my ASP.NET Core app generate http:// links on the hosted URL?

The Sandboxer proxy ends TLS and forwards plain HTTP. Set `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` so the app uses `X-Forwarded-Proto`. In our test that changed the `Link` header from `http://` to `https://`.

### Why does a second dotnet watch crash with an inotify error?

One `dotnet watch` on our solution used 72 of the 128 inotify instances the sandbox allows per user. Set `fs.inotify.max_user_instances=1024` in `/etc/sysctl.d` and run `sudo sysctl --system`.

### Is it worth snapshotting the NuGet cache?

Snapshot `bin` and `obj` together with it. With both, the build took 2.7 seconds; with only the NuGet cache it took 15.4. The download itself was 19 seconds or less.

## Related

- [Set up a Go dev environment in a cloud sandbox](/guides/go-dev-environment-in-a-cloud-sandbox)
- [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot)
- [SSH into a cloud sandbox from VS Code or Cursor](/guides/ssh-into-a-cloud-sandbox-from-vs-code-or-cursor)
- [Hosting](/docs/hosting)
