我的原创博文:https://www.prudkohliad.com/articles/deploy-next-js-to-vps-using-sst-2024-08-11
sst 是一个框架,可以让您轻松在自己的基础设施上构建现代全栈应用程序。 sst v3 使用 pulumi 和 terraform – sst 文档
在本指南中,我们将使用 sst 和 docker 在 hetzner vps 上部署 next.js 应用程序。本指南是我上一篇文章的后续内容。如果您在这里发现一些没有意义的内容,您很有可能会在那里找到答案 - 如何使用 docker 和 github actions 将 next.js 应用程序部署到 hetzner 上的 vps。
要将 sst 添加到项目中,请运行以下命令:
pnpx sst@ion init
这将显示一个交互式提示。选择“是”,然后选择“aws”:
确保安装了所有必需的软件包:
pnpm install
这将创建 sst.config.ts 文件,我们将在其中添加所有配置。
此外,还会创建一些其他文件/目录。让我们将它们添加到 .dockerignore,我们不希望它们最终成为 docker 镜像:
# sst .sst sst.config.ts tsconfig.json
这就是 sst 配置文件当前的样子:
/// <reference path="./.sst/platform/config.d.ts"></reference> export default $config({ app(input) { return { name: "next-self-hosted", removal: input?.stage === "production" ? "retain" : "remove", home: "aws", }; }, async run() {}, });
我们不打算使用aws,所以让我们将home参数设置为“local”:
/// <reference path="./.sst/platform/config.d.ts"></reference> export default $config({ app(input) { return { name: "next-self-hosted", removal: input?.stage === "production" ? "retain" : "remove", home: "local", }; }, async run() {}, });
现在可以开始向 run() 函数添加东西了。
为了使用 sst 在 hetzner 上创建 vps,我们需要一个 hetzner api 令牌。让我们生成一个新的。
在 hetzner 控制台中打开项目,导航到“安全”选项卡:
生成 api 令牌:
新的代币将添加到您的项目中:
令牌只会显示一次,请确保不要丢失。
添加 tls 和 hetzner 提供商:
pnpm sst add tls pnpm sst add hcloud pnpm install
为了在创建 hetzner vps 后执行进一步的命令,我们需要确保在创建过程中添加了 ssh 密钥。为此,我们将在本地创建一个 ssh 令牌,然后将其公共部分添加到 hetzner。在run函数中添加以下代码:
// in the run() function: // generate an ssh key const sshkeylocal = new tls.privatekey("ssh key - local", { algorithm: "ed25519", }); // add the ssh key to hetzner const sshkeyhetzner = new hcloud.sshkey("ssh key - hetzner", { publickey: sshkeylocal.publickeyopenssh, });
部署应用程序:
pnpm sst deploy sst ❍ ion 0.1.90 ready! ➜ app: next-self-hosted stage: antonprudkohliad ~ deploy | created ssh key - local tls:index:privatekey | created ssh key - hetzner hcloud:index:sshkey ✓ complete
您将看到一个新的 ssh 密钥已添加到 hetzner 中:
现在我们可以继续创建 vps 了。
以下命令将确保在您的项目中创建新的 vps:
// in the run() function: // create a server on hetzner const server = new hcloud.server("server", { image: "docker-ce", servertype: "cx22", location: "nbg1", sshkeys: [sshkeyhetzner.id], });
这里我使用 docker-ce 镜像,因为它已经安装了 docker。您可以使用 hetzner cloud api 列出所有可用的图像、服务器类型和数据中心。
验证服务器是否正确创建:
pnpm sst deploy sst ❍ ion 0.1.90 ready! ➜ app: next-self-hosted stage: antonprudkohliad ~ deploy | created server hcloud:index:server (34.5s) ✓ complete
您还应该能够在控制台中看到新创建的服务器:
为了在 vps 上构建应用程序 docker 镜像并能够创建网络、卷和容器,我们需要在本地计算机和 vps 上的 docker server 之间建立一座桥梁。为此,我们需要 docker 提供商:
pnpm sst add docker pnpm install
将 ssh 私钥存储在磁盘上,以便 ssh 客户端可以访问它。创建与 vps 上 docker 服务器的连接:
// at the top of the file: import { resolve as pathresolve } from "node:path"; import { writefilesync as fswritefilesync } from "node:fs"; // in the run() function: // store the private ssh key on disk to be able to pass it to the docker // provider const sshkeylocalpath = sshkeylocal.privatekeyopenssh.apply((k) => { const path = "id_ed25519_hetzner"; fswritefilesync(path, k, { mode: 0o600 }); return pathresolve(path); }); // connect to the docker server on the hetzner server const dockerserverhetzner = new docker.provider("docker server - hetzner", { host: $interpolate`ssh://root@${server.ipv4address}`, sshopts: ["-i", sshkeylocalpath, "-o", "stricthostkeychecking=no"], });
确保还将 ssh 私钥 id_ed25519_hetzner 添加到 .gitignore 和 .dockerignore,这样它就不会进入您的 github 存储库和 docker 镜像。
触发部署以验证更改:
pnpm sst deploy sst ❍ ion 0.1.90 ready! ➜ app: next-self-hosted stage: antonprudkohliad ~ deploy | created docker server - hetzner pulumi:providers:docker ✓ complete
现在我们可以在删除的 docker 服务器上构建 docker 镜像了:
// in the run() function: // build the docker image const dockerimagehetzner = new docker.image( "docker image - app - hetzner", { imagename: "next-self-hosted/next-self-hosted:latest", build: { context: pathresolve("./"), dockerfile: pathresolve("./dockerfile"), target: "production", platform: "linux/amd64", }, skippush: true, }, { provider: dockerserverhetzner, dependson: [server], } );
让我们触发部署看看一切是否正常:
pnpm sst deploy sst ❍ ion 0.1.90 ready! ➜ app: next-self-hosted stage: antonprudkohliad ~ deploy | log starting docker build | log image built successfully, local id "sha256:629a6cdfc298c74599a3056278e31c64197a87f6d11aab09573bc9171d2f3362" | created docker image - app - hetzner docker:index:image (36.0s) ✓ complete
现在,让我们检查 docker 镜像是否已到达服务器:
ssh root@116.203.183.180 -i ./id_ed25519_hetzner -o stricthostkeychecking=no -c "docker image ls" repository tag image id created size next-self-hosted/next-self-hosted latest 629a6cdfc298 about a minute ago 712mb
太棒了!
我们将创建两个网络:公共网络和内部网络。公共网络用于 nginx 连接的服务,即必须暴露于外部的服务(例如 next.js 应用程序或 api 服务器)。内部网络用于不应该暴露给外部的服务,例如postgres数据库、redis缓存:
// in the run() function: // setup docker networks const dockernetworkpublic = new docker.network( "docker network - public", { name: "app_network_public" }, { provider: dockerserverhetzner, dependson: [server] } ); const dockernetworkinternal = new docker.network( "docker network - internal", { name: "app_network_internal" }, { provider: dockerserverhetzner, dependson: [server] } );
触发部署:
pnpm sst deploy sst ❍ ion 0.1.90 ready! ➜ app: next-self-hosted stage: antonprudkohliad ~ deploy | created docker network - public docker:index:network (2.3s) | created docker network - internal docker:index:network (3.1s) ✓ complete
检查网络 app_network_internal 和 app_network_public 是否存在于远程:
ssh root@116.203.183.180 -i ./id_ed25519_hetzner -o stricthostkeychecking=no -c "docker network ls" network id name driver scope 0590360bd4ae app_network_internal bridge local e3bd8be72506 app_network_public bridge local 827fa5ca5de2 bridge bridge local dc8880514199 host host local f1481867db18 none null local
我们将创建一个卷来存储应用程序构建文件(.next 文件夹):
// in the run() function: // setup docker volumes const dockervolumeappbuild = new docker.volume( "docker volume - app build", { name: "app_volume_build" }, { provider: dockerserverhetzner, dependson: [server] } );
部署并验证 docker 卷 app_volume_build 是否存在于 vps 上:
pnpm sst deploy sst ❍ ion 0.1.90 ready! ➜ app: next-self-hosted stage: antonprudkohliad ~ deploy | created docker volume - app build docker:index:volume ✓ complete ssh root@116.203.183.180 -i ./id_ed25519_hetzner -o stricthostkeychecking=no -c "docker volume ls" driver volume name local app_volume_build
我们将运行一个一次性容器(也称为 init 容器)来构建 next.js 应用程序并将结果存储在 .next 文件夹中,该文件夹将通过我们上面创建的卷与主应用程序容器共享:
// in the run() function: // run a one-off container to build the app const dockerappbuildcontainer = new docker.container( "docker container - app build", { name: "app_container_build", image: dockerimagehetzner.imagename, volumes: [ { volumename: dockervolumeappbuild.name, containerpath: "/app/.next", }, ], command: ["pnpm", "build"], mustrun: true, }, { provider: dockerserverhetzner, } );
部署并通过日志验证构建是否成功:
pnpm sst deploy sst ❍ ion 0.1.90 ready! ➜ app: next-self-hosted stage: antonprudkohliad ~ deploy | created docker container - app build docker:index:container (1.1s) ✓ complete ssh root@116.203.183.180 -i ./id_ed25519_hetzner -o stricthostkeychecking=no -c "docker logs -f app_container_build" > next-self-hosted@ build /app > next build ▲ next.js 14.2.5 creating an optimized production build ... ✓ compiled successfully linting and checking validity of types ... collecting page data ... generating static pages (0/4) ... generating static pages (1/4) generating static pages (2/4) generating static pages (3/4) ✓ generating static pages (4/4) finalizing page optimization ... collecting build traces ... route (app) size first load js ┌ ○ / 142 b 87.2 kb └ ○ /_not-found 871 b 87.9 kb + first load js shared by all 87 kb ├ chunks/52d5e6ad-40eff88d15e66edb.js 53.6 kb ├ chunks/539-e1fa9689ed3badf0.js 31.5 kb └ other shared chunks (total) 1.84 kb ○ (static) prerendered as static content
现在我们将添加一个“runner”容器,它将使用构建容器的构建输出,并在下次启动时运行:
// in the run() function: const dockerappcontainer = new docker.container( "docker container - app", { name: "app", image: dockerimagehetzner.imagename, volumes: [ { volumename: dockervolumeappbuild.name, containerpath: "/app/.next", }, ], networksadvanced: [ { name: dockernetworkpublic.id }, { name: dockernetworkinternal.id }, ], command: ["pnpm", "start"], restart: "always", }, { provider: dockerserverhetzner, dependson: [dockerappbuildcontainer] } );
部署并验证应用是否启动成功:
pnpm sst deploy sst ❍ ion 0.1.90 ready! ➜ app: next-self-hosted stage: antonprudkohliad ~ deploy | created docker container - app docker:index:container (1.1s) ✓ complete ssh root@116.203.183.180 -i ./id_ed25519_hetzner -o stricthostkeychecking=no -c "docker logs -f app" > next-self-hosted@ start /app > next start ▲ next.js 14.2.5 - local: http://localhost:3000 ✓ starting... ✓ ready in 497ms
应用程序容器可能会失败,因为构建容器尚未完成构建,但它很快就会恢复并正常运行。
为了将文件上传到vps,我们需要安装命令提供程序和polumi包:
pnpm sst add @pulumi/command pnpm add -d @pulumi/pulumi pnpm install
确保 vps 上存在 /root/app 和 /root/app/certs 目录并上传 cloudflare origin server 证书:
// at the top of the file import { asset as pulumiasset } from "@pulumi/pulumi"; // in the run() function: // make sure that app directory exists new command.remote.command("command - ensure app directory", { create: "mkdir -p /root/app", connection: { host: server.ipv4address, user: "root", privatekey: sshkeylocal.privatekeyopenssh, }, }); // make sure that app/certs directory exists new command.remote.command("command - ensure app/certs directory", { create: "mkdir -p /root/app/certs", connection: { host: server.ipv4address, user: "root", privatekey: sshkeylocal.privatekeyopenssh, }, }); // copy certificates to the vps new command.remote.copytoremote( "copy - certificates - key", { source: new pulumiasset.fileasset( pathresolve("./certs/cloudflare.key.pem") ), remotepath: "/root/app/certs/cloudflare.key.pem", connection: { host: server.ipv4address, user: "root", privatekey: sshkeylocal.privatekeyopenssh, }, } ); new command.remote.copytoremote( "copy - certificates - cert", { source: new pulumiasset.fileasset( pathresolve("./certs/cloudflare.cert.pem") ), remotepath: "/root/app/certs/cloudflare.cert.pem", connection: { host: server.ipv4address, user: "root", privatekey: sshkeylocal.privatekeyopenssh, }, } ); new command.remote.copytoremote( "copy - certificates - authenticated origin pull", { source: new pulumiasset.fileasset( pathresolve("./certs/authenticated_origin_pull_ca.pem") ), remotepath: "/root/app/certs/authenticated_origin_pull_ca.pem", connection: { host: server.ipv4address, user: "root", privatekey: sshkeylocal.privatekeyopenssh, }, } );
复制 nginx 配置文件到 vps 并启动 nginx 容器:
// in the run() function: // copy nginx config to the vps const commandcopynginxconfig = new command.remote.copytoremote( "copy - nginx config", { source: new pulumiasset.fileasset( pathresolve("./nginx/production.conf") ), remotepath: "/root/app/nginx.conf", connection: { host: server.ipv4address, user: "root", privatekey: sshkeylocal.privatekeyopenssh, }, } ); // run the nginx container const dockernginxcontainer = new docker.container( "docker container - nginx", { name: "app_container_nginx", image: "nginx:1.27.0-bookworm", volumes: [ { hostpath: "/root/app/nginx.conf", containerpath: "/etc/nginx/nginx.conf", }, { hostpath: "/root/app/certs", containerpath: "/certs", }, ], command: ["nginx", "-g", "daemon off;"], networksadvanced: [{ name: dockernetworkpublic.id }], restart: "always", ports: [ { external: 443, internal: 443, }, ], healthcheck: { tests: ["cmd", "service", "nginx", "status"], interval: "30s", timeout: "5s", retries: 5, startperiod: "10s", }, }, { provider: dockerserverhetzner, dependson: [dockerappcontainer] } ); return { ip: server.ipv4address };
部署并验证 nginx 容器是否正在运行:
pnpm sst deploy sst ❍ ion 0.1.90 ready! ➜ app: next-self-hosted stage: antonprudkohliad ~ deploy | deleted docker container - app build docker:index:container | created command - ensure app/certs directory command:remote:command | created command - ensure app directory command:remote:command | created docker container - app build docker:index:container | created copy - certificates - cert command:remote:copytoremote (1.2s) | created copy - nginx config command:remote:copytoremote (1.2s) | created copy - certificates - key command:remote:copytoremote (1.2s) | created copy - certificates - authenticated origin pull command:remote:copytoremote (1.2s) | deleted docker container - app docker:index:container | created docker container - app docker:index:container (1.2s) | created docker container - nginx docker:index:container (7.1s) ✓ complete ip: 116.203.183.180 ssh root@116.203.183.180 -i ./id_ed25519_hetzner -o stricthostkeychecking=no -c "docker ps -a" container id image command created status ports names 9c2cb18db304 nginx:1.27.0-bookworm "/docker-entrypoint.…" 3 minutes ago up 3 minutes (healthy) 80/tcp, 0.0.0.0:443->443/tcp app_container_nginx 32e6a4cee8bc next-self-hosted/next-self-hosted:latest "docker-entrypoint.s…" 4 minutes ago up 3 minutes 3000/tcp app f0c50aa32493 next-self-hosted/next-self-hosted:latest "docker-entrypoint.s…" 4 minutes ago exited (0) 3 minutes ago app_container_build
可以看到,nginx 和应用程序运行顺利。
是时候确保 dns 记录指向正确的 ip 地址了(是的,也可以通过 cloudflare 提供商将其添加到 sst 配置中):
然后,我们可以打开应用程序并验证它是否有效:
恭喜!我们现在已经完成了 sst 潜水,可以享受新部署的应用程序了?
sst 使得清理变得非常容易 – 只需运行 pnpm sst remove ,整个设置就会消失:
pnpm sst remove SST ❍ ion 0.1.90 ready! ➜ App: next-self-hosted Stage: antonprudkohliad ~ Remove | Deleted Docker Container - Nginx docker:index:Container (1.9s) | Deleted Docker Container - App docker:index:Container | Deleted Docker Container - App Build docker:index:Container | Deleted Docker Image - App - Hetzner docker:index:Image | Deleted Docker Volume - App Build docker:index:Volume (2.1s) | Deleted Docker Network - Public docker:index:Network (3.1s) | Deleted Docker Network - Internal docker:index:Network (3.2s) | Deleted Copy - Nginx Config command:remote:CopyToRemote | Deleted Docker Server - Hetzner pulumi:providers:docker | Deleted Copy - Certificates - Authenticated Origin Pull command:remote:CopyToRemote | Deleted Command - Ensure app/certs directory command:remote:Command | Deleted Copy - Certificates - Key command:remote:CopyToRemote | Deleted Command - Ensure app directory command:remote:Command | Deleted Copy - Certificates - Cert command:remote:CopyToRemote | Deleted Server hcloud:index:Server (16.8s) | Deleted SSH Key - Hetzner hcloud:index:SshKey | Deleted SSH Key - Local tls:index:PrivateKey ✓ Removed