1---
2title: Docker Swarm
3description: Provision a swarm and deploy replicated services to it, from local dev to a remote VPS
4---
5
6Docker Swarm is orchestration built into Docker: replicated services, rolling
7updates, and overlay networking, without running Kubernetes. Alchemy models
8the swarm itself (`Docker.Swarm`) and its workloads (`Docker.Service`,
9overlay `Docker.Network`), targeting any engine through a `Docker.Context`.
10
11## Create the swarm
12
13```typescript
14import * as Docker from "alchemy/Docker";
15
16const swarm = yield* Docker.Swarm("swarm");
17```
18
19`Docker.Swarm` is an idempotent `docker swarm init`: it turns the engine into
20a single-node swarm whose node is both manager and worker. Regular
21`docker run` and `docker build` usage of the engine is unaffected, and if the
22resource is destroyed the node leaves the swarm again.
23
24## Use an existing swarm
25
26A swarm you didn't create with `Docker.Swarm` is still fully usable. There
27are two ways in, depending on who should own the swarm's lifecycle.
28
29### Reference it
30
31```typescript
32const vps = yield* Docker.Context("vps", {
33 docker: "host=ssh://deploy@example.com",
34});
35
36const web = yield* Docker.Service("web", {
37 context: vps,
38 image: "nginx:alpine",
39 replicas: 3,
40});
41```
42
43Services never require a `Docker.Swarm` resource — they only need the engine
44behind `context` to already be a swarm manager. Pass a `Docker.Context` (or a
45plain context name) and Alchemy manages the workloads while the swarm itself
46stays someone else's responsibility: destroying the stack removes the
47services and leaves the swarm untouched.
48
49### Adopt it
50
51```typescript
52const swarm = yield* Docker.Swarm("swarm").pipe(Alchemy.adopt(true));
53```
54
55Adoption makes the swarm's lifecycle part of the stack. An engine that is
56already in swarm mode is treated as foreign — the deploy fails with
57`OwnedBySomeoneElse` until you opt in with `adopt(true)` (or `--adopt`).
58After adoption Alchemy manages the swarm like one it created, **including
59dissolving the node's membership on destroy** — so adopt only when this
60stack should own the swarm, and reference otherwise.
61
62## Deploy a service
63
64```typescript
65const web = yield* Docker.Service("web", {
66 context: swarm,
67 image: "nginx:alpine",
68 replicas: 2,
69 ports: [{ external: 8080, internal: 80 }],
70});
71```
72
73Passing the swarm as `context` does two things: the service deploys **after**
74the swarm exists, and it targets the same engine. The swarm keeps `replicas`
75copies of the container running, and the published port is reachable on every
76node through the routing mesh.
77
78## Connect services on an overlay network
79
80```typescript
81const network = yield* Docker.Network("app-net", {
82 context: swarm,
83 driver: "overlay",
84});
85
86const db = yield* Docker.Service("db", {
87 context: swarm,
88 image: "postgres:18-alpine",
89 networks: [{ name: network.name, aliases: ["postgres"] }],
90});
91```
92
93Overlay networks span the whole swarm. Services attached to the same network
94reach each other by service name or alias — here, other services resolve the
95database as `postgres`.
96
97## Write an effectful service
98
99```typescript
100import * as Effect from "effect/Effect";
101import * as HttpServerResponse from "effect/http/HttpServerResponse";
102
103const api = yield* Docker.Service(
104 "api",
105 {
106 context: swarm,
107 main: import.meta.url,
108 port: 3000,
109 ports: [{ external: 8080, internal: 3000 }],
110 replicas: 2,
111 },
112 Effect.gen(function* () {
113 return {
114 fetch: Effect.gen(function* () {
115 return yield* HttpServerResponse.json({ ok: true });
116 }),
117 };
118 }),
119);
120```
121
122Instead of a pre-built image, `main` points at this module. Alchemy bundles
123the program, bakes it into a content-addressed image built on the swarm's
124engine, and serves the returned `fetch` handler with a Bun HTTP server on
125`port`. The image is only rebuilt when the code changes; unchanged redeploys
126are no-ops.
127
128## Add background work
129
130```diff lang="typescript"
131+import { ServerHost } from "alchemy/Server/Process";
132+import * as Schedule from "effect/Schedule";
133
134const api = yield* Docker.Service(
135 // ...
136 Effect.gen(function* () {
137+ const host = yield* ServerHost;
138+ yield* host.run(
139+ Effect.log("heartbeat").pipe(
140+ Effect.repeat(Schedule.spaced("30 seconds")),
141+ Effect.asVoid,
142+ ),
143+ );
144 return {
145 fetch: Effect.gen(function* () {
146 return yield* HttpServerResponse.json({ ok: true });
147 }),
148 };
149 }),
150);
151```
152
153`host.run` registers a long-running loop that executes alongside the HTTP
154server inside every replica.
155
156## Deploy to a remote machine
157
158### Point a context at the machine
159
160```typescript
161const vps = yield* Docker.Context("vps", {
162 docker: "host=ssh://deploy@example.com",
163});
164```
165
166A Docker context is a pointer to another engine. With an SSH endpoint, every
167Docker operation — including image builds — runs on that machine; nothing
168needs to be installed there beyond Docker.
169
170### Initialize the swarm there
171
172```typescript
173const swarm = yield* Docker.Swarm("swarm", {
174 context: vps,
175 advertiseAddr: "10.0.0.1",
176});
177```
178
179The same `Docker.Swarm`, now initializing the remote engine. `advertiseAddr`
180is the address other swarm members would use to reach this manager — Docker
181requires it when the host has several network interfaces. Like the other init
182props, it only applies when the swarm is first created.
183
184### Services follow the swarm
185
186```typescript
187const web = yield* Docker.Service("web", {
188 context: swarm,
189 image: "nginx:alpine",
190 replicas: 3,
191});
192```
193
194Nothing else changes: services take the swarm as `context`, so the same stack
195deploys locally or to the VPS depending on which engine the swarm targets.
196
197## Grow to more nodes
198
199Adding machines to the swarm is host-level setup (there is no resource for
200node membership yet). On the manager, print the join command:
201
202```sh
203docker swarm join-token worker
204```
205
206Run the printed `docker swarm join --token ... <manager-ip>:2377` on each new
207machine, and label nodes to target them with `placement.constraints`:
208
209```sh
210docker node update --label-add zone=eu <node>
211```
212
213Alchemy keeps managing the swarm's workloads through the manager. Note that
214`main`-bundled images are built on the manager's local store — multi-node
215swarms need images on a registry every node can reach (build with
216`Docker.Image` + `registry` and pass the pushed ref as `image`).
217
218## Local dev and production
219
220Putting it together: one stack that deploys a single replica to your local
221swarm during `alchemy dev`, and three replicas to a VPS over SSH in
222production.
223
224```typescript
225import * as Alchemy from "alchemy";
226import * as Docker from "alchemy/Docker";
227import * as Effect from "effect/Effect";
228
229export default Alchemy.Stack(
230 "swarm-app",
231 {
232 providers: Docker.providers(),
233 state: Alchemy.localState(),
234 },
235 Effect.gen(function* () {
236 const dev = yield* Alchemy.ALCHEMY_DEV;
237
238 const vps = dev
239 ? undefined
240 : yield* Docker.Context("vps", {
241 docker: "host=ssh://deploy@example.com",
242 });
243
244 const swarm = yield* Docker.Swarm("swarm", { context: vps });
245
246 const app = yield* Docker.Service("app", {
247 context: swarm,
248 image: "ghcr.io/acme/app:latest",
249 replicas: dev ? 1 : 3,
250 ports: [{ external: 8080, internal: 80 }],
251 });
252
253 return { service: app.name };
254 }),
255);
256```